Each decision has one owner. The plain-language version, grouped by owner, is the Decision board.
Full record: docs/DECISIONS.md
Decisions
Calls that need a person, not a developer. Each one is written to be decided from this page alone: what it is, why it matters, the options with their pros and cons, and what happens after a choice. When a decision is taken, write the date and the choice under Decision; cutover ones are also recorded in RISK-ASSESSMENT.md §5.
Who decides (2026-10-01). Every decision has one owner, named on the line under its heading. Others named as also are asked, but the owner answers. Thomas owns the technical ones.
| Owner | Decides |
|---|---|
| Business owner | What members get, the cutover date, accepting a risk in writing |
| Marketing | Member emails, campaigns, newsletters, what members are told |
| Finance | Prices, reports, the audit trail, money that does not match a plan |
| Legal / compliance | Consent, personal data, how long it is kept, PCI paperwork |
| Technical (Thomas) | How it is built, run and secured |
Decisions 1–17 were mostly taken by Thomas alone. Those owned by someone else are now marked taken, please confirm: the owner confirms or asks for a change. Reviewed 2026-10-03: the calls Thomas took on 1–3 October were checked against the same rule. #35, #37 (the split of powers), #40 and #41 wait for their owner's confirmation; #38/#39 (where a rule is stored) and #42 (a bug fix) were relabelled Technical. docs/progress/2026-10-03.md §D. Reviewed again 2026-10-05, this time the fifteen Technical decisions, looking 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) gained a Business-owner confirmation; #6's notes are asked about on #22; #36 informs Finance; #10 now schedules MFA before cutover; #44 was relabelled Technical and added to the table. docs/progress/2026-10-05.md. The plain-language version for colleagues, grouped by owner, is DECISION-BOARD.md.
Numbers never change. About 130 references across the code and docs point at them. New decisions take the next free number.
| # | Decision | Owner | Status |
|---|---|---|---|
| 1 | The 172 Community-readable posts (MIG-1) | Business owner | Decided 2026-08-26 — accept the lockout · confirm |
| 2 | Member emails after cutover (MIG-2) | Marketing | Decided 2026-09-29 — B · confirm; the win-back target (M7) is still open · M9 amended by #53 (2026-10-16) |
| 3 | Keeping Mailchimp in sync at cutover (MIG-4) | Technical | Decided 2026-10-02 — C, arming switch + reverse sweep |
| 4 | PMPro category-level restrictions (MIG-5) | Business owner | Decided 2026-10-15 — A, automated in the plugin (the sync flattens category rules onto their posts) · confirm; the live-site run is Thomas's |
| 5 | Migration rehearsal rig (R1–R7, R12–R13) | Technical | Decided 2026-08-29 · R8 host open; legacy prices moved to #34 |
| 6 | Member notes + admin team grants | Technical | Decided 2026-09-02 |
| 7 | Reports: what Finance needs before it can audit from FMS | Finance | Open — B, E and C still to choose |
| 8 | Customizable newsletters (admin + team admin) | Technical | Decided 2026-09-02 · Business owner confirms NL4/NL5 (a team-wide mute beats the seat) |
| 9 | Who sends member email | Technical | Decided 2026-09-12 |
| 10 | Security hardening after the 2026-09-22 review | Technical | Decided 2026-10-02 · Marketing confirms B (guest free sign-up ends) · MFA before cutover (2026-10-05) |
| 11 | Live-migration hardening: Mailchimp creates, past-due at cutover (MIG-15) | Legal / compliance | Decided 2026-09-23 · confirm |
| 12 | Where logs live | Technical | Decided 2026-09-24 — B, built the same day |
| 13 | Who gets the newsletter archive | Business owner | Decided 2026-09-27 — C, per plan · confirm; which plans tick the box is still open |
| 14 | Who did what: an activity trail for admins and members | Technical | Decided 2026-09-28 — B, one activity table |
| 15 | The member account page after the journey review | Business owner | Decided 2026-09-28 — status header + fixes now; cancel at period end after cutover · confirm · the cancel built 2026-10-14 under #49 |
| 16 | One page per job: the theme's old pages, its links, and the plugin's pages | Technical | Decided and built 2026-09-29 |
| 17 | Who curates the archive's list of newsletters | Business owner | Decided 2026-09-29 — edit in the archive, FMS flag says who (tick on the staff account since 2026-10-01) · confirm |
| 18 | The cutover date | Business owner | Open |
| 19 | Telling members about the cutover (MIG-13) | Marketing | Open |
| 20 | If FMS stops answering after cutover (MIG-12) | Business owner | Decided 2026-10-27 — C, 24 hours (a site left on the PMPro fallback behaves the same once PMPro is off) · built the same day, Member Gate 0.57.0 · rig check owed |
| 21 | A renewal payment fails: keep reading while Stripe retries? (FIN-3) | Business owner | Decided 2026-10-27 — B, keep reading up to 30 days while Stripe retries · built the same day (dunning grace on the Stripe renewal webhook; closes FIN-3) |
| 22 | Deleting a member's data on request (SEC-10) | Legal / compliance | Open |
| 23 | Newsletters sent to a shared inbox: who may unsubscribe | Legal / compliance | Open |
| 24 | Free newsletters for new sign-ups: ticked or unticked | Marketing | Open |
| 25 | Team prices at 16 and 17 seats, and the largest team | Finance | Open |
| 26 | What members may do in Stripe's billing portal | Business owner | Open |
| 27 | Two memberships at once, and more than one team admin | Business owner | Open |
| 28 | Reading statistics in the newsletter archive | Marketing | Open |
| 29 | Archive pictures load from Mailchimp's servers | Legal / compliance | Open |
| 30 | A campaign to turn free members into paying ones | Marketing | Open |
| 31 | Monthly donations | Business owner | Open |
| 32 | Alerts when Stripe or Mailchimp stop answering (MIG-8) | Technical | Decided 2026-10-02 — A, built |
| 33 | PMPro's membership-manager role and delayed starts (MIG-3) | Business owner | Open |
| 34 | Three old Stripe prices that match no plan | Finance | Open |
| 35 | What a Friend or Follower reads in the archive | Business owner | Decided 2026-10-01 — B, per newsletter, Fanatic by default · confirm; which newsletters to open is still open |
| 36 | How often an open team invoice is re-checked with Stripe | Technical | Open · Finance informed |
| 37 | Who can do what in the console | Technical | Decided 2026-10-01 — B, four roles (amended 2026-10-10, a fifth role: Membership manager; 2026-11-07, a sixth: Developer, with the Technical tier) · Business owner confirms what each role may do, and who holds which |
| 38 | Who defines the archive's Who can read choices | Technical | Decided 2026-10-02 — A, a reading level on each plan · superseded by #39 |
| 39 | Where the archive's reading rules live | Technical | Decided 2026-10-02 — B, reader groups in the archive · Business owner confirms that a new plan reads nothing until a manager ticks it |
| 40 | Who may move a website login onto a member (MIG-7) | Business owner | Decided 2026-10-03 — A, Support settles the conflict only · confirm |
| 41 | What the Mailchimp clean-up removes from non-members (FIN-10) | Marketing | Decided 2026-10-03 — A, paid tags only · confirm |
| 42 | Replayed Stripe events and the payment-confirmation window (FIN-9) | Technical | Decided 2026-10-03 — A, a bug fix · for information |
| 43 | Claude reads FMS: who, what, how (SEC-17) | Business owner | Decided 2026-10-03 — A, a per-person read-only connector, off until switched on · Business owner and Legal / compliance confirm |
| 44 | May Claude read a sandbox, for testing? | Technical | Decided 2026-10-04 — D, one owner switch per environment, off everywhere by default |
| 45 | Plan changes from the console | Technical | Decided 2026-10-10 — staff may change a plan, charge now or send an invoice · Business owner and Finance confirm |
| 46 | Moving a free or bank-paid member to Stripe billing by invoice | Technical | Decided 2026-10-10 — staff may move one, FMS emails the invoice, nothing granted until paid · Business owner and Finance confirm |
| 47 | Two-step sign-in for every console account (SEC-8) | Technical | Decided and built 2026-10-11 — A, an authenticator app for every role, no opt-out · for information |
| 48 | Opn Payments (Omise Thailand) as a second provider (FIN-14, FIN-15, FIN-16, SEC-19, OPS-3) | Technical | Decided 2026-10-13 — A, beside Stripe in one registry, test only · Business owner chooses primary or secondary (card 48) |
| 49 | Where a member manages their billing, and what a cancel does | Business owner | Decided 2026-10-14 — the provider's portal first; FMS's own billing page only where none exists (Opn); a cancel ends at the period end, then free Community unless another membership · for information (card 49) |
| 50 | Opn (Omise) prices in THB: hand-typed or derived from the USD catalog price | Business owner · also: Finance | Decided 2026-10-15 — C: derived from the provider's rate, cached daily with a 5 % jump guard, allowance switch; typed Opn prices removed |
| 51 | What an Omise-paying member is shown and charged: baht prices, honest display, stored renewal terms | Business owner · also: Finance, Technical | Decided 2026-10-15 — B built: the price the chosen card type pays is shown before Pay (the USD list price beside a baht price, never "about $"), the page's figure is a binding quote the server checks; Q2/Q4 built with #50 C · open: Q6 (AMEX on Omise or Stripe SG), Q8 (pre-renewal reminder) |
| 52 | Moving from PMPro segment by segment (MIG-12, MIG-17, MIG-18) | Business owner · also: Technical, Marketing | Decided 2026-10-15 — B, a per-member hand-over, on the owner's request; built the same day, not yet rehearsed · plan re-cut 2026-10-27: everyone on one morning, segments are the rollback (note under §52) · 2026-11-03: one gate, no soak: PMPro off in the same window as the hand-over (note under §52) · 2026-11-07: no segments; the console's Migration tab is the Cutover page (note under §52) · decide: the date (#18) · MIG-18 closed 2026-10-27 (no PMPro codes outstanding, owner) |
| 53 | Membership emails decided per payment connection, and Opn's own receipts, renewal reminders and failed-payment emails | Business owner | Decided 2026-10-16 — per connection, independent of Settings → Email; the three Opn emails built · confirm |
1. The 172 Community-readable posts — MIG-1
Owner: Business owner · taken by Thomas, please confirm
Decision (2026-08-26): accept the lockout. Free Community members keep their account and newsletters but lose article access to those 172 posts; nothing is edited, the migration runs as is. Plan to tell the Community cohort before the flip (see decision 2).
2. Member emails after cutover — MIG-2
Owner: Marketing · also: Business owner (the Stripe dashboard emails, M8) · taken by Thomas, please confirm; M7 still needs a number
Re-cut 2026-09-29. The first version (2026-08) assumed FMS could send no member email. Decision 9 changed that, so the options below replace the old A/B/C.
What it is. PMPro emails members today: payment receipts, renewal reminders, "your membership was cancelled", "your membership has expired" and "your membership expires soon". All of them stop the day PMPro is switched off. Since decision 9 (2026-09-12, extended 2026-09-28) FMS has its own mail path: an SMTP relay on Settings → Email, a queue that retries and shows Failed with Retry, branded HTML mail, a Member email light on Health, a bounce address, and a sender that can carry a one-click unsubscribe. So the question is no longer whether anyone can send these emails, but who sends which one. This is also the first step of Track 2: grant metric 2, recover lapsed subscribers via a reactivation campaign, due by the end of October.
Why it matters. Without a choice, two things money depends on stay silent. A member whose free or bank-paid membership is ending is never warned, and there is no win-back campaign, which is what metric 2 is measured on.
Already covered once PMPro is off:
| Sent by | What is still needed | |
|---|---|---|
| Payment receipt | Stripe, per account | Dashboard setting Successful payments on, both accounts |
| Renewal coming up (Stripe-billed annual members) | Stripe | Dashboard setting upcoming renewals on |
| Card payment failed, card about to expire | Stripe (dunning stays on, policy 2026-06-30) | Dashboard settings. The July rescue turned TH's failed-payment emails off; confirm and turn back on |
| A renewal needs the member to confirm (3DS) | Stripe, plus the FMS account-page banner | Dashboard setting. Without it the renewal only reaches members who happen to visit the site |
| Team invoice: confirm your email, invoice ready, paid, renews in 14 days | FMS | Nothing |
| Gift: claim link, and an offer to continue 14 days before it ends | WordPress plugin | Nothing (gift mail is switched on in the plugin) |
| Signup and email-change confirmations | WordPress plugin | Nothing |
The gaps:
| # | Who it is for | |
|---|---|---|
| G1 | Your membership ends soon | Comp and bank-paid (external) members. They do not renew themselves |
| G2 | Your membership has ended | The same members, on the day access drops to Community |
| G3 | Your cancellation is confirmed | Anyone who cancels, or whom staff cancel |
| G4 | Welcome | A member's first paid, comp, gift or team membership |
| G5 | Come back, with a discount | Members who used to pay and no longer do. This is metric 2 |
M1 — Who sends G1 to G5. The roadmap's first question.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Accept the gaps | Stripe settings only. | Nothing to build. | Comp and bank-paid members lose access unwarned. No campaign, so metric 2 is not met. | Two dashboard settings |
| B. FMS sends G1–G4, Mailchimp sends G5 | FMS queues the four account emails through the relay. FMS keeps an fms:lapsed tag on Mailchimp contacts, and staff send the win-back campaign from Mailchimp with a link that fills in the discount code. | Every member FMS knows gets G1–G4, and the console shows whether each one went out. The campaign gets Mailchimp's editor, open and click numbers and A/B tests, which is what the grant asks staff to iterate on in October. Marketing unsubscribes stay with Mailchimp. | Two senders, each for what it does best. G1–G4 wording lives in code until the M5 follow-up. | About 4–5 dev days |
| C. FMS sends everything | As B, and G5 is an FMS sequence too (7 and 30 days after a membership lapses). | One place, measured in FMS. | Staff cannot change the campaign without a developer. No open or click data. FMS takes on marketing-mail compliance. | B plus 1–2 days |
| D. Mailchimp sends everything | FMS pushes tags (fms:expiring, fms:lapsed, fms:new) and every email is a Mailchimp automation. | Staff own all the wording. Closest to the grant's "built on the existing Mailchimp stack". | A member missing from the audience gets nothing, because FMS never creates contacts on a re-sync (decision 11). Tag delay matters for a 3-day warning. FMS cannot say whether a member's email went out. The grant already names Mailchimp deliverability as a risk. | About 2 dev days, plus staff build the automations |
The details, if B (most also hold for C):
| # | Decision | Recommended | Rejected, and why |
|---|---|---|---|
| M2 | When G1 goes out | 14 days and 3 days before the end. Not for gifts or teams, which already get their own 14-day email. | 7 days: out of step with the gift and team-invoice emails members already get. |
| M3 | Gift emails | Stay on WordPress this round (decision 9, EM7). | Moving them into the FMS queue now: another day of work, and gift mail is off by default. A follow-up. |
| M4 | Unsubscribe | G1 and G4 carry a one-click unsubscribe (mail header plus a footer link) that stops these emails only. G2 and G3 are about the member's own account and always go. | No unsubscribe: Gmail and Yahoo expect one on non-essential mail. An unsubscribe on everything: a member could stop hearing that their access ended. |
| M5 | Who writes the wording | Fixed text in FMS, with a Preview per email on Settings → Email. Editable subject and paragraphs in the console later, once the wording settles (handover, grant §4.8). | Editable now: a day more, before anyone knows which wording works. |
| M6 | The win-back discount | One shared code, for example WELCOMEBACK: first payment only, one use per member, new members only off, with an end date. A new link, /checkout?plan=…&code=WELCOMEBACK, fills it in. | A unique code per member: more schema and a mail merge, with no better measurement. |
| M7 | How metric 2 is counted | Reports and the Dashboard gain Reactivated: members who used the code after an earlier paid membership ended. The existing Returning count stays beside it. The grant gives no number, so set one here: ___ members back by 30 November. | Counting opens and clicks: Mailchimp shows them, but they are not recovered members. |
| M8 | Stripe's own emails | On for both accounts: successful payments, upcoming renewals, failed payments, expiring cards, and payment confirmation (3DS). The go-live tick sheet (RUNBOOK §6b.1) gains a column for them. | Leaving TH's failed-payment emails off: contradicts the 2026-06-30 policy that Stripe dunning stays on. |
| M9 | Where each email is switched on | Settings → Email, per environment, every email off until staff turn it on. A sandbox copy starts with all of them off again. | One switch for every environment: a sandbox cloned from production holds about 22k real addresses and would mail them. |
| M9, amended 2026-10-16 (#53) | Where each email is switched on, per payment connection | A member whose card is charged by a Stripe or Opn account gets the emails switched on for that account (its page on Settings → Connections); everyone else (free access, bank transfer, gifts, team seats) gets the ones on Settings → Email. The two never combine. | The one environment-wide switch of the first M9: a Stripe account set to send its own emails then doubles them, and an Opn account, which sends none, cannot be given more. |
Recommendation. B, with M2–M9 as written, and a number for M7.
After deciding. B or C: built in the Sep 29 – Oct 10 slot, and switched on after the cutover date check. End dates imported from PMPro decide who gets G1 first, so staff review them before turning G1 on. D: the tags and the discount link are built, and staff build the automations. A: the accepted gaps are recorded in RISK-ASSESSMENT MIG-2, and metric 2 stays unmet. Every option: the Stripe settings in M8 at the go-live session.
Decision (2026-09-29): Thomas chose B, with M2–M9 as written. The M7 number is still to be set. Built the same day (migration 0059, plugin 0.44.0, services::lifecycle). Calls taken while building: the four emails share the team-invoice queue rather than a new one, so Retry, Health and the WordPress fallback cover them; G2 also goes to gift recipients (it is the day their access drops, after the gift's own 14-day offer) but not to a grant that funds a team; G3 is sent from the three real cancel points (staff Stop, a member's cancel, a switch to free), not from the shared cancel, which a team admin removing a colleague also uses; G2 and G3 skip a member who still holds another membership above Community (a duplicate removed from Consolidation, a comp grant beside a card); G4 skips a switch or a continuation (another paid membership live or ended in the last 30 days) and says "Welcome back" after a longer gap; a sandbox gets an amber warning, not a refusal, so the rehearsal rig can test the emails through Mailpit; Won back counts any code, not one named code. docs/progress/2026-09-29.md.
3. Keeping Mailchimp in sync at cutover — MIG-4
Owner: Technical · also: Marketing (lapsed members keep paid newsletter tags until the clean-up runs)
Corrected 2026-08-29. The first write-up said nothing could bulk-correct tags after import. Wrong: Consolidation → System ↔ Mailchimp already reads every member's live tags, lists the drift, and has "Re-sync all drifting" (one outbox event per drifting member, throttled at ≤10 concurrent calls). What is missing is narrower: every Mailchimp fix in Consolidation refuses unless the connected audience is in test mode (
plan_tag_fix→blocked_live) — a brake with no arming switch. On cutover day, against the real audience, the button would refuse.
What it is. During the import, FMS deliberately does not push anything to Mailchimp (it would flood the queue with ~23k events). PMPro's Mailchimp add-on, which keeps tags and the PMPLEVELID field current today, stops when PMPro is deactivated. Consolidation can find and re-sync the drift afterwards — but only once the live-audience brake can be armed.
Why it matters. Until the re-sync runs, everyone keeps whatever tags PMPro left them — including members-only newsletter tags on people who have since lapsed, the original bug this system exists to fix.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Arm the brake, run "Re-sync all drifting" at cutover | Add an explicit arming switch (ALLOW_LIVE_MAILCHIMP_WRITES, mirroring the Stripe brake) so Consolidation's fixes work against the live audience during the cutover window; run the re-sync as a runbook step. | Uses the screen that already exists; only drifting members are written, not all 23k; the same screen verifies afterwards. Brake stays off outside the window. | A few hours of steady Mailchimp traffic on the day (fewer than a blind push). Adds one more armed-window procedure to the runbook. | Half a day: the switch + tests + a runbook line. |
| B. Accept drift until members are active | Do nothing; tags correct themselves as members touch the system. | Zero work. | Lapsed members keep paid newsletters for weeks or months. Consolidation shows thousands of drift rows you can't act on. | None now; ongoing support cost. |
| C. A + a reverse sweep | As A, plus a periodic Mailchimp→FMS check that flags contacts carrying members-only tags with no FMS member behind them. | Closes the gap in both directions. | More code; paging the whole audience costs API budget per run. | A + about two days. |
Recommendation. A before cutover; C as a follow-up (already tracked as FIN-10).
After deciding. A/C: the arming switch is built and rehearsed against the Mailchimp stub in the rehearsal rig; the "Re-sync all drifting" step goes into the migration runbook's verification phase.
Decision: C, taken 2026-10-02 (Thomas): the ALLOW_LIVE_MAILCHIMP_WRITES arming switch and the runbook step before cutover, and the Mailchimp → FMS reverse sweep (contacts carrying members-only tags with no member behind them), built with FIN-10. Built 2026-10-02. The switch gates every Consolidation Mailchimp fix in the production environment (the stored mode label turned out always to say test, so it could not be the gate); the clean-up is Phase C step 6 of the migration runbook.
2026-09-23. Two things changed underneath this question (see #11): a re-sync can no longer create a Mailchimp contact, so "Re-sync all drifting" only ever corrects members who are already in the audience (an absent paying member is now reported as Not in the Mailchimp audience instead); and CRM events drain on their own outbox lane at
OUTBOX_CRM_CONCURRENCY, so a 20k re-sync no longer holds payment work back. The brake question itself (option A's arming switch) is unchanged and still open.
4. PMPro category-level restrictions — MIG-5
Owner: Business owner · also: Technical (runs the query on the live site)
What it is. PMPro can restrict a whole category of posts to members. The restriction sync only copies per-post rules into FMS; category rules are skipped with a note. Every post that is gated only by a category rule becomes public the moment PMPro is off.
Why it matters. This is a silent content leak: nothing blocks the flip, the count only appears in the sync tool's summary, and there is no alarm afterwards.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Convert to per-post rules before cutover | List every post gated only by a category rule on the live site; give each an explicit per-post restriction; re-run the sync. | No code. Result is exact and visible in the sync count. The plugin's model stays simple. | Manual (or a WP-CLI script) pass over the live site; new posts added to a gated category later need a per-post rule too — an editorial habit to set. | An hour or two with a script; longer by hand if the number is large. |
| B. Teach the plugin category rules | Extend the gate so a post is members-only when its category is. | Editors keep working the way they do now. Future posts covered automatically. | Plugin change + tests; gating logic gets a second source of truth; the sync tool and the "default-deny" design (2026-07-27) need updating. | About a day. |
| C. Accept the leak | Let those posts become public. | Nothing to do. | Paid content becomes free without anyone deciding which. Not recommended. | None. |
Recommendation. A — the first step (listing the affected posts) is needed anyway to know how big the problem is; if it turns out to be hundreds of posts, reconsider B.
After deciding. A: I write the WP-CLI query that lists category-only-gated posts; you run it on the live site and we look at the number together. B: it goes on the plugin's roadmap before cutover.
Decision: A, automated in the plugin — 2026-10-15 (Technical moved first; the Business owner confirms on the board). Instead of a hand-run WP-CLI pass, both PMPro syncs — the Member Gate's Sync restrictions from PMPro button (0.50.0) and the migration assistant's restrictions job (0.4.0) — now give every post a category rule covers its own gate row, through one shared function (FMS_MG_Restrictions::flatten_pmpro_category_rules()). It mirrors PMPro's pmpro_has_membership_access(): the post's own terms (no ancestor categories; tags too from PMPro 3.8.1), rules on a deleted level ignored, and the lowest level across the category rules and the post's own per-post rules wins. Every status but trash/auto-draft is covered, so a scheduled article is locked the day it publishes. A read-only preview sits under the sync button, the stale-row cleanups keep the flattened posts, the deactivation readiness check now fails on a category-covered post with no gate row, and the rehearsal export (06_restrictions_export.sh) does the same flattening. Still true of A: a post added to a formerly locked category after the last sync gets no row (re-run the sync before PMPro is deactivated; after that, an editorial habit), and a category Community could read becomes paid-only like any Community rule. Not yet run on the rehearsal rig or the live site. docs/progress/2026-10-15-mig5-category-rules.md.
5. Migration rehearsal rig — decided 2026-08-29
Owner: Technical · also: Finance (the three legacy prices, now #34)
Taken in conversation while planning the rig (plan file for-now-the-concern-foamy-bee.md); recorded here so they are not re-litigated. Detail: docs/progress/2026-08-29.md, docs/migration-runbooks/local-rehearsal.md.
| # | Decision | Choice |
|---|---|---|
| R1 | Where the rehearsal runs | Laptop for iteration loops, VPS for the dress rehearsal — same tooling. |
| R2 | Sandbox Stripe books | Full history: all 2,896 live subscriptions mirrored, one per live subscription, on the matching account (Phase B). |
| R3 | Historical fidelity | Status-faithful (price/interval/quantity/status/instrument shape); timestamps are creation-day. |
| R4 | Test WordPress | The real live users + PMPro rows (from the .wpress), not synthetic-only. |
| R5 | Identities | Pseudonymised consistently on every side: u<hash>@mirror.frontiermyanmar.net (migration/pseudonym/SPEC.md). No real address anywhere in the rig. The subdomain must never get a mail server. |
| R6 | Sequencing | Phased, one plan: A rig → B mirror → C scenarios. |
| R7 | Mailchimp | Never a real audience from the rig (per-contact billing, consent). A local stub stands in; the live audience is touched only at cutover, on members already in it. |
| R12 | WordPress-side migration tooling | A separate, disposable FMS Migration Assistant plugin (wizard); deleted after cutover. |
| R13 | Live host | Docker (nginx + php-fpm) behind Cloudflare; owner has SSH/docker too. PHP limits generous, Cloudflare's 100 s is the binding one → every assistant job is chunked. |
Still open from that plan: R8 dress-rehearsal host and hostnames (Technical); the un-mappable legacy Stripe prices, now decision #34 (Finance); fresh Stripe dumps (migration/live-profile/fetch.sh) to pair with the 2026-08-29 WordPress export.
6. Member notes + admin team grants — decided 2026-09-02
Owner: Technical · also: Legal / compliance (a note is personal data about the member; whether it is deleted with them is asked on #22, 2026-10-05)
Taken in conversation while planning the notes field on New member (plan file can-you-create-note-structured-swing.md). Detail: docs/progress/2026-09-02.md.
| # | Decision | Choice | Why (and what was rejected) |
|---|---|---|---|
| N1 | Where a note lives | New append-only member_notes table, subscription_id nullable. | Works with no plan; keeps history with author + time. Rejected: a single subscriptions.note column — no author, no history, nowhere to go when no plan is granted. |
| N2 | Recording the admin | admin_user_id UUID without FK + admin_email snapshot. | admin_users lives in the boot DB (auth_pool); a sandbox's copy is empty, so an FK would refuse every sandbox grant. Cost: no referential guarantee; the email survives an admin's removal, which is what a byline wants anyway. |
| N3 | Atomicity | Grant tx = subscription + outbox event + team rows + note. | INVARIANT #3. Cost: members.rs composes the tx itself instead of create_with_event; a create_member with no plan stays two statements (no event involved). |
| T1 | Team-plan grant from the admin forms | Provision the team in the grant tx, operator-named, name required in the UI (server falls back to "<owner>'s team"). | Closes the orphan (billing, no roster) the Consolidation back-fill had to repair. Rejected: keeping two txs like checkout — a crash between leaves the orphan again. |
| T2 | Team-name rules | Trim; ≤ 120 chars counted in chars(); a name on an individual plan is ignored. | Burmese script is multi-byte; a stale hidden field must not 400. |
| T3 | Rename | POST /admin/api/teams/{id}/rename, admin-only, window.prompt on Team detail. | All team mutations are POST verbs; matches the page's one-field-edit style. Member-facing rename stays out of scope (09 §11 gap narrowed, not closed). |
7. Reports: what Finance needs before it can audit from FMS — open
Owner: Finance
What it is. The Reports tab (2026-09-02) shows net revenue per settlement currency: this month, last month, and a 12-month chart by source (subscriptions, donations, gifts, bank transfer). It is a management view. It answers how much did we net, not prove it. An audit is about proving it, and today the tab cannot on its own.
Why it matters. Finance closes the books against bank statements and Stripe. If FMS numbers cannot be traced to a transaction and reconciled to a deposit, Finance will keep two systems, and FMS revenue figures will not be trusted in a board pack.
What is already audit-grade. Every Stripe row carries the charge id, the balance-transaction id, the exchange rate and Stripe's raw fee breakdown, so each figure traces back to Stripe. Hand-entered bank transfers record who entered them and when. Refunded money is excluded rather than silently counted, including refunds that arrive before the fee was fetched.
The gaps, as an auditor would list them.
| # | Gap | Today | What an auditor expects |
|---|---|---|---|
| G1 | No transaction list | Monthly totals only; subscription payments on the member page; a guest's donation or gift is a line item nowhere | Every payment as a row: date, payer, kind, charged, gross, fee, net, rate, Stripe ids, status |
| G2 | No export | — | CSV of G1 for Excel / the external auditor |
| G3 | No bank reconciliation | Rows are bucketed by the day Stripe settled the charge | Bank receives payouts days later, batched, minus payout fees; a month in Reports ≠ that month's deposits |
| G4 | Refunds vanish | A refunded payment drops out of the totals; partial refunds and fee reversals untracked | Gross, refunds and net as separate lines so the movement is visible |
| G5 | Hard delete of hand-entered payments | Any admin can delete, no trace; no edit (typo ⇒ delete + re-enter) | Void with a reason, keep the row; edits logged |
| G6 | Fixed period, UTC | Last 12 calendar months in UTC | Custom range, fiscal year, Bangkok-time months to match local statements |
| G7 | Pre-cutover history | PMPro payments are not in the ledger and never will be | Known and accepted; the first months after cutover are incomplete by design |
Options. Not exclusive — pick a set.
| Option | Closes | Effort | Pros | Cons |
|---|---|---|---|---|
| A. Ledger page + CSV export — a filterable list of every settlement (date range, currency, kind, status, account) with "Download CSV" | G1, G2 | 1–2 days | Auditor gets the row-level truth and takes it into their own tools. Served from local state, no Stripe calls. | Still charge-dated (G3 stays). |
B. Void instead of delete, with a reason; edit amounts — status = voided, voided_by, reason; Reports ignores voided rows | G5 | ½–1 day | Every correction leaves a trail. Cheap. | Slightly more UI (a reason prompt). |
C. Payout reconciliation — payout.paid webhook + a payouts table, each settlement linked to the payout that carried it; a "By payout" view | G3 | 3–5 days | Reports can then match bank deposits one-to-one. Uses the same read-only worker pattern. | New webhook event on every account (re-provisioning, rotates signing secrets — RUNBOOK §6b). Biggest piece of work. |
D. Refunds as lines — keep refunded rows in the totals as negative movements; track partial refunds from charge.refunded.amount_refunded | G4 | 1 day | Standard finance presentation. | Spec 06 forbids card refunds; volume may never justify it. |
| E. Period controls — date range, Bangkok-time month buckets | G6 | ½ day | Matches local statements. | Bucketing is a one-line SQL change but it moves existing numbers between months once. |
| F. Do nothing in FMS — Finance audits in the Stripe Dashboard per account and takes bank transfers from a DB export (RUNBOOK §6d) | — | 0 | No work. | Two Stripe accounts, two dashboards, one spreadsheet; FMS totals stay untrusted. |
Recommendation. A + B now (about three days), E when Finance asks for it, C only if Finance wants FMS to replace their Stripe payout reconciliation rather than sit beside it. D only if refunds actually happen.
What we can do now, with no code. Written up as the "for now" checklist in the meeting brief and RUNBOOK §6d:
- Reconcile Stripe → bank in the Stripe Dashboard of each account (Reports → Balance → payout reconciliation CSV: fee, net, exchange rate, per charge). FMS Reports is the combined view on top.
- For the row-level ledger, ops exports
payment_settlementsto CSV on request (RUNBOOK §6d). - For bank transfers, put the bank reference in the method field ("Bank transfer · KBank 240901-17") when recording the payment, so the row can be matched to the statement.
- Never delete a recorded payment silently. Add a member note saying what was wrong and what replaced it, then delete and re-enter. The note is the trail until option B exists.
- Know that months are UTC: a Bangkok payment before 07:00 on the 1st lands in the previous month.
Decision. A + B's first half, taken 2026-09-14. A is built: Reports now carries a payments ledger — every settlement with charged / gross / fee / net, currency, rate, Stripe ids and who recorded it — filterable by period, kind, status and currency, searchable by payer, charge id or the free-text collection method, and downloadable as CSV in full. A subscriber list ships beside it on the same window. G1 and G2 are closed. G4 was closed in part on 2026-09-12 (partial refunds tracked) and now in full for presentation: refunded and disputed rows stay on the ledger rather than disappearing from it.
Still open: B (void-not-delete for a hand-entered payment), E (period controls exist, but months remain UTC), and C — the ledger is charge-dated, so a month in Reports is still not a month of bank deposits. The practical answer for now is the one written into the ledger itself: put the bank reference in the collection method, then match the CSV against the statement by reference and amount. docs/progress/2026-09-14.md.
8. Customizable newsletters — decided 2026-09-02
Owner: Technical · also: Business owner (confirms NL2: nobody can hand out a newsletter a plan does not include; and, since the 2026-10-05 review, NL4/NL5: a team admin's mute beats the seat and nobody can restore it for one seat — please confirm, Decision board card 8)
The question that started it. Could an admin and a team admin choose which newsletters go out, and would that fight with Consolidation — would a newsletter somebody deliberately switched off be reported as a missing tag?
The answer. No, and the reason is structural. There is exactly one computation of what Mailchimp should hold for an address (crm_state::desired_state_for_address). The CRM worker pushes it, and Consolidation derives both sides of its drift diff from it. A switched-off newsletter is sent to Mailchimp as "off" rather than left out, so it is not in the expected set and cannot be missing from it. If Mailchimp still holds it, that reads as stale — remove it, which is the truth and is actionable. Verified against a running app on 2026-09-02, both directions.
That makes the rule for this feature, and for anything like it later: every suppression is applied inside that one function. A rule computed anywhere else is exactly how a false "missing tag" would be born.
Detail: docs/progress/2026-09-02.md.
| # | Decision | Choice | Why (and what was rejected) |
|---|---|---|---|
| NL1 | Where a team-wide choice lives | New team_newsletter_mutes (team_id, tag_name) table; presence = muted. | Per-member choices stay in newsletter_preferences, so parts 1 and 2 needed no new table. Rejected: a muted boolean (invites a meaningless third state); a column on teams (no per-newsletter row, no audit). |
| NL2 | Direction | Subtract-only. A plan decides what a seat is entitled to; an admin or team admin can only take a newsletter away. | Nobody can hand out paid content by editing a newsletter list. Rejected: letting a team add newsletters its plan doesn't include. |
| NL3 | What a team mute reaches | Only the tags the team's own seat subscription grants, and only members-only ones. | Every WordPress signup also holds a free community membership; without the seat scoping, a team muting a shared newsletter would silence one the member gets in their own right. Community newsletters are excluded for the same reason. |
| NL4 | Team admin vs the member's own choice | A team admin may mute a seat, and un-mute only what a team admin muted. An FMS admin may clear any opt-out (a support act). | A colleague's unsubscribe is theirs. Needs newsletter_preferences.source (member / admin / team_admin). Rejected: a team admin who can flip anything. |
| NL5 | Lifting a team-wide mute | Only team-wide. Nobody — member or admin — can turn it back on for one seat. | Otherwise the team's setting and the seat's silently disagree with no way to tell which won. |
| NL6 | The old Mailchimp shortcut | Removed. Subscription lifecycle events now recompute the whole address instead of applying a delta. | That delta was a second definition of the desired state; every new rule had to be repeated in it or push and report would drift apart. Cost: a few more cheap local queries per event, against one Mailchimp round trip. |
| NL7 | Who gets the screens | Team admin in WordPress (team-wide + per seat); FMS admin in the console (per member, and a team's set). | The console needs it to answer support calls; set_by_user_id is left null there, so a row says plainly that FMS did it, not a team admin. |
| NL8 (2026-09-14) | A plan edit is a grant change | Changing a plan's newsletters resyncs every granting member from the same transaction; unchanged (or re-ordered) tags enqueue nothing. The audience is the granting-statuses set, shared with apply_canonical_tags. | Editing a plan used to change nothing for the people on it, so "add this newsletter to Community" silently meant "for future members only". An opt-out still survives, because the resync recomputes the subtract-only desired state rather than applying a delta (NL6). |
| NL9 (2026-09-14) | Where a team admin chooses scope | On their own My Newsletters page: "Make changes for: your account / the whole team", defaulting to their account. A query parameter picks the view; the POST carries the scope. | Team-wide changes were reachable only from the team page, so the admin most likely to want one had no way to know it existed. The default stays personal because a team admin editing their newsletters usually means their own; and a GET still never writes. |
9. Who sends member email — decided 2026-09-12
Owner: Technical · also: Business owner (EM9: whose Google Workspace account signs in to the relay)
The question that started it. The team-invoice confirmation link was not arriving. FMS only queues member mail (team_invoice_mail); the WordPress plugin's cron polls it and sends through wp_mail, so the answer to "where is this sent from, and how do I set up Gmail?" was "in WordPress, with a mail plugin" — invisible from the FMS console and dependent on WP-Cron.
The answer. FMS sends member email itself once a relay is saved on Settings → Email, and WordPress delivery is the fallback when nothing is configured. Thomas chose this over configuring Gmail in WordPress (fastest, no FMS change) and over a both-at-once hybrid.
| # | Decision | Choice | Why (and what was rejected) |
|---|---|---|---|
| EM1 | Where the relay is configured | Console, email_settings singleton in the boot DB (auth_pool), password encrypted with SecretBox. SMTP_* in .env stays as a fallback. | One mailbox per instance, like the R2 target; a sandbox must not need its own Gmail. Rejected: per-environment rows (a sandbox with no relay would silently fall back to WordPress mid-test). |
| EM2 | Who delivers | email_settings::resolve is the single truth. Some ⇒ FMS's 30 s loop leases and sends; /v1/team-invoices/outbound returns empty batches. None ⇒ WordPress drains the queue as before. | Both deliverers lease the same rows with the same lease_until, so a switch mid-flight cannot double-send. Rejected: a "both" mode (two senders, one inbox, no way to tell which failed). |
| EM3 | Pausing | An enabled = false console row pauses FMS without falling back to .env or WordPress. | An explicit pause should mean "hold the queue", not "some other sender takes over". |
| EM4 | Save semantics | Store, then run a connection test and record it; a failed test is shown, not fatal. | Gmail rejects a wrong App Password instantly, so the operator sees the real error and fixes one field. Rejected: refuse-to-save (forces retyping everything; no SMTP server in tests). |
| EM5 | Failure handling (amended 2026-09-28) | Per-row attempts / last_error / sent_via / message_id. Each failure is classified (services::mail_verdict): transient backs off 1, 2, 4 … 60 min and parks after 12 attempts; relay refused (credentials, IP allowlist, TLS) backs off and never parks; permanent (bad address, too large) parks at once. Parked = failed_at + fail_reason, shown as Failed with Retry. member_email Health goes amber after 15 min stuck or on any parked row. | The console must answer "did it go out, and if not why" without reading logs. Was: 5 min × attempts, capped at 1 h, retried forever, so a mistyped address was retried hourly for ever. Rejected: park on every 5xx (Google answers a wrong relay rule with a 5xx, and every queued email would need its own Retry after one fix); keep retrying forever (hides a bad address). |
| EM6 | Manual verification | Admin Mark email verified on an awaiting row, audited in verified_by, same effect as the link. | Staff already speak to team admins; a lost email should not block an invoice. Rejected: auto-verifying WordPress-confirmed signups only (already exists via /v1/team-invoices/verified-email, but does not cover FMS-native signups). 2026-09-29: that WordPress path now releases a waiting request too, and the FMS link or this button lifts the website's own confirm-your-email hold (/v1/team-invoices/verified-email/status, plugin 0.45.0). |
| EM7 | Scope | Team-invoice member mail + admin alerts. Gift emails stay on the WordPress /v1/gifts/outbound cron for now. | One queue at a time; gifts have their own delivery contract and templates in the plugin. Follow-up if the same relay should carry them. |
| EM8 (2026-09-28) | Bounce address | Optional Bounce address on Settings → Email (SMTP_BOUNCE_ADDRESS in .env) sets the SMTP envelope sender only. Blank = the From address. | Bounces to noreply@ are never read. An envelope-only change keeps the header From and DMARC alignment untouched. Rejected: a fixed bounces@ (needs a mailbox that may not exist, and reports are then lost); a Sender: header (mail apps show "on behalf of"). |
| EM9 (2026-09-28) | Google Workspace relay sign-in | SMTP AUTH as the membership manager's Workspace account with an App Password, From noreply@, relay rule Only addresses in my domains + Require SMTP Authentication + Require TLS. IP allowlist stays possible (blank username). | Thomas's choice: works from any server, including a sandbox or a moved VPS. Cost: the App Password dies when that person's password changes or 2-Step Verification goes off, and email stops until a new one is pasted (the console names the error). Rejected: IP allowlist only (breaks on any move or second server); a dedicated smtp-relay@ user (a paid seat for one password). |
| EM10 (2026-09-28) | Dry run and test sends | EMAIL_SINK=log or file:<dir> diverts every send (production refuses to boot with it). mail-test CLI in the image sends one test through the live relay. Message-ID = <row or outbox id@from-domain>, stable across retries. | A laptop must be able to run the whole mail path without mailing anyone; the CLI proves SPF/DKIM/DMARC from the server itself. A stable Message-ID lets Gmail collapse the rare crash-after-accept duplicate. |
Detail: docs/progress/2026-09-12.md.
10. Security hardening after the 2026-09-22 review — decided 2026-10-02
Owner: Technical · also: Business owner and Marketing (B ends guest sign-up on the FMS checkout page)
The archive fixes (SEC-11/12) were approved for immediate implementation. The wider findings below are recorded for Thomas to choose and schedule later; none of these options is approved by the archive change. See RISK-ASSESSMENT §3.2.
A. Administrator authentication — SEC-8/13
| Option | Pros | Cons |
|---|---|---|
| 1. Minimal: reject default/empty production bootstrap passwords, rate-limit login and verify a dummy hash for unknown emails | Small, focused change closes default credentials, brute force and timing enumeration | Leaves CSRF, password change and stolen-session revocation unresolved; existing defaults still need rotation |
| 2. Full: option 1 plus admin user/password management UI, a session-revocation table and CSRF header checks | Staff can rotate credentials and revoke access; closes the operational gaps together | Schema and UI work; all admin mutations and sessions need migration/testing |
| 3. MFA/TOTP, on top of the baseline controls | Protects against a stolen password | Enrollment, recovery and support burden; does not replace rate limits, CSRF or revocation |
Recommendation for later approval: option 1 first, option 2 before broad administrator access; consider MFA after recovery ownership is clear. No option selected yet.
B. Free checkout authentication — SEC-7
| Option | Pros | Cons |
|---|---|---|
1. Move the $0 branch behind the WordPress auth gate | Reuses the identity proof already required by paid checkout; WordPress already handles community signup | Ends guest community signup directly on the FMS checkout page |
2. Keep guest signup with RateLimiter, email validation and Turnstile | Preserves direct FMS signup | More integration and failure cases; syntactic email validation does not prove mailbox ownership |
Recommendation for later approval: option 1 if WordPress remains the signup owner. Confirm the need for direct guest signup before choosing; neither option is implemented here.
C. FMS security headers and checkout CSP — SEC-14
| Option | Pros | Cons |
|---|---|---|
| 1. Shared security-headers layer only | Small rollout for frame protection, nosniff and HTTPS/HSTS policy | Leaves script injection on payment pages without CSP containment |
| 2. Headers plus nonce CSP | Constrains scripts on sensitive payment pages as well | Needs per-response nonces on the four inline window.__* blocks in cardform.rs, pricing.rs, checkout.rs, donate.rs, and real Stripe/3DS checks |
Recommendation for later approval: option 2, with payment/challenge verification before rollout; option 1 can be a staged first delivery. Decide policy and timing before implementation.
D. WordPress service key storage — SEC-9
| Option | Pros | Cons |
|---|---|---|
1. Require FMS_SERVICE_KEY in server configuration and refuse option storage | Keeps the powerful key out of routine WordPress database exports | Needs server access to configure/rotate; removes dashboard-only setup |
| 2. Keep the option but encrypt with a salt-derived key | Retains the setup UI and protects a database-only leak | Salt rotation needs key recovery/re-entry; a full WordPress compromise still decrypts it |
| 3. Status quo plus service-key IP allow-list | Least disruption; bounds remote use of a leaked key | Key stays plaintext; an attacker on the allowed WordPress host still has access |
Recommendation for later approval: option 1 plus an IP allow-list where deployment supports it; option 2 if dashboard setup is essential. Existing keys/exports are unchanged in this pass.
E. Small fixes bundle — SEC-15/16
No product decision needed; schedule the implementation and validation:
- Rate-limit
/donateand/giftcard submits, preserving SCA completion and legitimate retries. - Reject credential-bearing SMTP
security=noneand production R2http://; retain explicit local test/stub settings. - Add
fms_mg_mail_throttle()to the team-pricing contact form. - Add uninstall cleanup for plugin secrets, keeping deactivation reversible.
- Validate the portal redirect with
fms_mg_stripe_url(). - Protect migration exports outside public uploads or with explicit web-server denial and an authenticated download path on Apache and nginx.
Owner/scheduling remains open. The archive implementation does not include this bundle.
Decision (2026-10-02, Thomas):
- A — option 1 now (default-password refusal, login rate limit per address and per account, dummy hash). Option 2 (CSRF checks, revoking other sessions) before broad console access. MFA (option 3): before cutover (Thomas, 2026-10-05; was not scheduled. Leaving it unscheduled would have been a risk acceptance, which is the Business owner's call, so Thomas scheduled it instead). Built 2026-10-11 as #47 (TOTP for every role). Option 2 (CSRF, revoking other sessions) is still to come.
- B — option 1: the
$0branch sits behind the WordPress sign-in. A product change: guest sign-up on the FMS checkout page ends. Marketing and the Business owner are asked to confirm (Decision board card 10). - C — option 1 now (the shared headers, with
frame-ancestors 'none'as the only CSP directive); option 2 (the nonce CSP on the payment pages) in November, with a 3DS retest. - D — option 1: the plugin requires
FMS_SERVICE_KEYinwp-config.php. The IP allow-list stays a per-deployment choice. - E — the SEC-15 half now (donate/gift throttles, cleartext SMTP and
http://R2 refused in production); the SEC-16 plugin half in November.
11. Live-migration hardening — decided 2026-09-23
Owner: Legal / compliance · also: Finance and Business owner (B) · taken by Thomas, please confirm
Context: a pre-cutover review of every real Stripe and Mailchimp call on the PMPro → FMS path (docs/progress/2026-09-23.md, MIG-15). Two of the seven findings were policy, not code, and the owner decided both in the same session. Recorded here so the reasoning survives; the code holds the rule (crm::worker::presence_for, repository::subscriptions::update_from_stripe).
A. May a Mailchimp re-sync create a contact?
Every re-sync used to upsert with status_if_new: subscribed, so a member absent from the audience (never signed up, unsubscribed, archived) was added the next time anything touched them — a consent problem, and at cutover a 20k backfill would have done it wholesale.
| Option | Pros | Cons |
|---|---|---|
| 1. Never create on re-sync — only a signup may (chosen) | Consent stays with the member; a backfill, plan edit or Consolidation fix can't resurrect an unsubscribed contact; the rule is one payload flag (create_if_absent) set by the real signup origins | A paying member who is absent from the audience stays absent until they sign up again — Consolidation reports them as Not in the Mailchimp audience; since 2026-10-16 an owner or membership manager can press Add to the audience there (one member, or every absent member of a check), an admin-initiated, audited signup that adds nobody who was refused, is still held by PMPro or gets no newsletter (docs/progress/2026-10-16-production-env.md) |
| 2. Create everywhere, as before | Every paying member ends up tagged | Re-adds people who unsubscribed; Mailchimp bills per contact; compliance exposure |
| 3. Create on re-sync only for members with an active paid subscription | Closes the tagging gap for payers | Still overrides an explicit unsubscribe; the "paid" test would live in the CRM worker, duplicating crm_state |
Decision: option 1. AddressTagResync never creates, whatever the flag.
B. A member past_due on migration day
PMPro was still letting them read. Under FMS, past_due grants nothing (only an SCA hold does), so a plain import would have locked out every member mid-dunning on day one.
| Option | Pros | Cons |
|---|---|---|
| 1. Import as grace until Stripe's dunning resolves (chosen) | No day-one lockout for a dunning the member didn't start under FMS; Stripe's own webhooks settle it (recovered → active, canceled/unpaid → ended); bounded by the later of the paid-through date and GRACE_PERIOD_DAYS; import-time only — later dunning behaves as before | Up to a month of access for a member who never pays; a second grace_source (dunning) beside the structural one; the sweep may lapse a team before Stripe recovers it (unlikely inside the window) |
2. Import as past_due, no access | Nothing new to build; matches FMS's steady-state rule | Lockout on day one for members PMPro was still serving; support load at the worst moment |
3. Import as active | Simplest for the member | Lies to the dashboard and Reports (invariant #4); nothing ends it if Stripe gives up |
Decision: option 1. Recorded in RISK-ASSESSMENT MIG-15.
12. Where logs live — decided 2026-09-24
Owner: Technical · also: Legal / compliance (email addresses in logs, kept 30 or 180 days)
What it is. FMS logs through tracing to three sinks at once: plain text on stdout (Docker's json-file driver, read with docker compose logs) or a daily-rotated file when LOG_DIR is set; a 2,000-line in-memory ring shown on the console's Logs screen; and the app_logs Postgres table (INFO and above, LOG_RETENTION_DAYS, default 30), which is what the Logs screen searches. The money trail is not in the logs: every webhook (processed_webhooks), every side effect (outbox_events, dead letters kept), every reroute (payment_reroute_events), every payment (payment_settlements), every alert (system_alerts) and every admin action on a member (member_notes, authorized_by) is already a queryable table with its own retention. Logs are the debugging aid around that trail. Today's volume is a few hundred rows a day.
Why it matters. Text logs are hard to search across days and vanish on container recreate unless LOG_DIR is set. Whatever is chosen must still run without outside help after November (grant §4.8), be backed up, and not hold member PII longer than the app does.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Status quo | Text + app_logs at 31 days, console Logs screen. | Nothing to run. | Search is level + substring only; no request or member id to pivot on; 31-day horizon. | 0 |
| B. Structured logs in Postgres (recommended now) | Add a request id, member id, Stripe account, event type and outbox id as columns on app_logs (from tracing span fields); JSON stdout; per-target retention (money-adjacent targets longer than HTTP noise); Logs screen gets filters for those columns and a "show everything about this member / webhook" link from the member page and Dead letters. Logs ride the existing nightly R2 backup. | One database, one backup, one login; the console already has the screen; the questions staff actually ask ("what happened to this member on Tuesday") become one click; handover stays simple. | Postgres is not a log engine: at millions of rows per month it would need partitioning; no dashboards or regex over years of history. Fine at this volume for years. | 2–3 days |
| C. Log database beside FMS: Loki or VictoriaLogs | Ship Docker stdout (all containers, nginx too) with Grafana Alloy or Vector into VictoriaLogs (single binary, ~100 MB RAM, LogsQL) or Loki + Grafana. Keep app_logs for the console. | Full-text search over every service on the host, not only FMS; long retention is cheap; Grafana dashboards and alert rules if Loki. | Two or three more containers to run, update, back up and explain in the handover; another login and query language for staff; logs leave the FMS permission model, so PII policy must be settled first. | 1–2 days to stand up, ongoing ownership |
| D. ClickHouse | Vector → ClickHouse, Grafana or the console's Logs screen querying it over SQL. | The strongest search and analytics engine of the four; SQL; compresses well; would also fit a future analytics store. | Heavy for one app on one VPS: a JVM-free but memory-hungry server that wants its own tuning, backups (clickhouse-backup), merges and upgrades; a second place holding member data; the most to hand over. Built for terabytes, we have megabytes. | 2–3 days plus real ongoing ownership |
Recommendation. B now: it answers the actual complaint (persistent, searchable, everything in one place) inside the database and console that already exist, and costs nothing at handover. Set LOG_DIR on the VPS today as a free interim so text logs survive container recreation. Choose C (VictoriaLogs first) only if searching logs becomes a daily task or nginx/WordPress logs need to be in the same search; D only if an analytics warehouse is wanted for other reasons, which Reports does not need. Whatever the option, decide PII in logs (email addresses appear in some messages) and retention together with it.
After deciding. A: nothing. B: a roadmap item under "code owed", spec 08 (health/ops) gets the field list. C/D: a compose service, a backup line in ops/RUNBOOK.md, a staff-guide page, and the PII/retention rule in RISK-ASSESSMENT.md.
Decision: B, taken and built 2026-09-24 (owner: "add structured columns"). Migration 0051; spec 08 FR-19; LOG_RETENTION_DAYS_MONEY (180) beside LOG_RETENTION_DAYS (30); log_history health component with LOG_TABLE_ALERT_MB. Revisit C (VictoriaLogs) only if nginx/WordPress logs need to join the same search. PII: log lines still carry email addresses in some messages; the retention windows above are the rule until a redaction pass is scheduled. docs/progress/2026-09-24.md.
Amended 2026-10-01 (cost of writing, pruning and reading). Same option, cheaper to run:
- Each row's retention class is stored when it is written (
app_logs.retention_class, migration0061), so the daily prune ranges over an index instead of re-testing every old row. - Pruning left the log writer.
services::retentionruns it daily in batches of 5,000, so the writer's queue never waits on a prune. - The "own retention" above was not true until now. Completed
outbox_eventsandprocessed_webhooksmarks were kept forever. They now go afterLOG_RETENTION_DAYS_MONEY(180 days); pending, in-flight and dead events are never pruned. The money itself stays in the domain tables (member_invoices,payment_settlements,payment_reroute_events,subscriptions), which are not pruned. Chosen by Thomas 2026-10-01. - History is read from the boot database, where it is written (a sandbox showed a frozen copy).
- The query uses only the filters in use, matches typed text literally, and pages with
before/after.
docs/progress/2026-10-01.md.
13. Who gets the newsletter archive — decided 2026-09-27
Owner: Business owner · also: Marketing (metric 4 wording) · taken by Thomas, please confirm; which plans tick the box is still open
What it is. The archive (archive/, live since 2026-09-24) unlocked on the tier_fanatic capability, which only the Fanatic tier and team seats carry. Three things in the product said otherwise: grant metric 4 reads "open to every active paid member" (ROADMAP.md), the Friend tier's suggested pricing copy promised "full access to the Frontier archive, including magazine issues and newsletters" (frontend/src/lib/planCopy.ts), and the public pricing FAQ said paid membership includes "the full archive" (src/pricing.rs).
Why it matters. Either members who were promised the archive cannot open it, or the promise and the grant note are wrong. The grant report cites metric 4 as met.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Every paid member | Gate on members_only. | Matches the grant wording and the copy. | Takes a Fanatic benefit away from Fanatic; not reversible without code. | Small |
| B. Fanatic only | Keep tier_fanatic; correct the copy and the grant note. | No code. | Metric 4 is only partly true; still code to change later. | Copy only |
| C. Per plan (chosen) | A box on each plan, Grants access to the newsletter archive; FMS issues an archive_access capability to members holding a ticked plan. | The owner decides, and can change it, from the console; the first step of spec 04 FR-5. | A schema column, a new capability both consumers must switch to in one deploy. | 1 day |
Decision: C, taken 2026-09-27 (owner: "configurable per plan") and built the same day. Migration 0052 adds plans.grants_archive, backfilled so nothing changes until someone ticks a box: Fanatic (yearly and monthly) and every team plan. The free Community plan can never grant it, because an expired comp grant is downgraded to it. Plugin 0.38.0 and the archive server default to archive_access; the plugin migrates a stored tier_fanatic once. The Friend copy now promises the website archive of past magazine issues, and the pricing FAQ says selected memberships include the newsletter archive. Whether Friend and Follower should get it is now a console setting, not a code change — metric 4 is fully true only once every paid plan has the box ticked. Spec 10; docs/progress/2026-09-27.md.
14. Who did what: an activity trail for admins and members — decided 2026-09-28
Owner: Technical · also: Legal / compliance (member IP addresses kept 180 days)
What it is. FMS cannot say who performed an action or how a member reached it. Every admin API request already identifies the acting admin (AdminApiSession), but only a handful of writes record it in their own column (authorized_by on grants, verified_by on team-invoice requests, started_by on Stripe imports, member notes); the other ~100 mutating admin routes leave no actor anywhere. On the member side, the request span carries the member id into app_logs, but app_logs is a lossy debugging sink by design (decision #12: drops rows under pressure, pruned at 30/180 days, free text) and the member JWT carries no origin, so a call from the WordPress plugin is indistinguishable from a script holding the same token. The only complete trail today is handoff_events (website sign-ins, spec 10 G-8), shown on the member page.
Why it matters. Roles are coming: once several staff share the console with different permissions, "which admin changed this member's plan on Tuesday" must have an answer, and it must survive the answer being inconvenient. Security review also needs "which member did what, from where" per member. Both are the same question from two sides.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Actor columns per table | Keep adding *_by columns where an action lands (the current pattern). | No new table; each column is next to the thing it explains. | ~100 routes to touch one by one; no timeline, no "everything this admin did"; deletes and failed attempts leave nothing; members not covered. | Weeks, piecemeal |
| B. One activity table (chosen) | activity_events, append-only, written by one middleware on every mutating admin route and by the member-facing handlers: time, actor (admin / member / WordPress service key / Stripe webhook, with id), action (route template), target ids, channel, outcome, client IP and user agent when known, request id, redacted details. The admin id also joins the request span so app_logs lines name the admin. Two views of the one table: an Audit screen filtered by admin, and an Activity card on the member page (actor or target = this member) that absorbs Sign-ins from the website. Roles later add a role column stamped at the time of the action; nothing else changes. | Complete by construction (middleware, not call sites); one place to search; members and admins alike; handoff_events folds in. | A new table and screen; the plugin must mint a via claim and forward the member's address; care to keep secrets out of details. | 3–4 days |
C. Reuse app_logs | Add admin_id to the span and search the Logs screen. | Almost free. | Lossy and pruned, free text, no target ids — fails the moment it is needed as evidence. | Hours |
Decision: B, taken 2026-09-28 (owner). Parameters fixed at the same time:
- Retention 180 days (
ACTIVITY_RETENTION_DAYS, pruned daily by the log writer), the same window as the money-adjacent logs. Not forever: the table holds member IP addresses. - Admins: writes only. Every
POST/PUT/PATCH/DELETEunder/admin/api, including refused ones (403/409) — a refused attempt is exactly what a role review wants to see. Reads are not recorded. - Members: reads too, where a read is an action a member takes (opened invoices, previewed a plan change, redeemed a hand-off, opened the portal). Background reads made about a member (webhooks, outbox resyncs) are recorded with the webhook / worker as actor and the member as target.
- Channel is a claim. Tokens minted with the WordPress service key carry
via: "wordpress"; the FMS member session cookie ismember_pages, a hand-off redeem isarchive, the public card forms arecheckout/donate/gift. A token withoutviarecordsunknown, never a guess. - Member IP and user agent are optional. The WordPress plugin sends the member's address and user agent at token mint (stamped into the signed member token) and as
X-FMS-Client-IP/X-FMS-Client-UAheaders on its other service-key calls. FMS reads the headers only on requests that carry the service key (same rule asTRUST_PROXY: a header is evidence only when the sender is known). A service call that does not carry them — a cron, a CLI export, a future app — records nothing for those two fields rather than the caller's own address. The member page must never show the WordPress host as a member's location. - Details are redacted at the middleware, by an allow-list per route, not a deny-list: passwords, API keys, SMTP secrets, card tokens and Stripe signatures cannot reach the table because only named fields are copied.
Built 2026-09-28 (spec 08 FR-21 — FR-20 was already the archive component). Migration 0057 creates activity_events in every database but FMS writes and reads it only in the boot database, with the environment each row happened in, so the trail survives sandbox switches and resets; a sandbox cloned from production truncates its copy. The old handoff_events rows moved in and that table is gone. One middleware (crate::activity) records every console write and the exports; member bearer calls carry their channel, IP and user agent in the token (auth::TokenOrigin); the card forms and /auth/session record the browser's own address; the archive's server-to-server redeem records no address, because the one it has is the archive's. The console gains Settings → Audit and the member page's Activity card; Logs gain an admin filter. Plugin 0.39.0 sends the origin. docs/progress/2026-09-28.md.
Not built, on purpose or for later:
- Webhook and worker rows ("Stripe renewed this membership", "Mailchimp resync") are not written yet. The money trail for them already exists (
processed_webhooks,outbox_events,payment_settlements); addingsystem-actor rows is a follow-up if staff want them on the member's Activity card. - The archive's own visitor address on a redeem: the archive would have to forward it, and the redeem is authenticated by a signed token, not a service key. Left out until decided.
- A backup restore rewinds the trail, because the trail lives in the boot database the dump replaces. The restore's own row is written after it.
ops/RUNBOOK.mdsays so.
The roles work (spec 08, later) builds on this table: add the role to each row at the time of the action; nothing else changes. Done 2026-10-01 (#37): activity_events.role carries the role the admin held when they acted, refusals included; Audit shows it beside the name.
Amended 2026-10-01 (cost of writing, pruning and reading), chosen by Thomas:
- Routine member reads once an hour. The website reads a member's membership on most page views, so the trail grew by one row per page view. A successful read of membership, invoices or team is now kept once per member, route and channel per clock hour. Writes and failed reads are still kept every time.
- Off the request path, never dropped. A request stamps its row and queues it. One writer batches the rows in at most every 0.5 s. A full queue makes the request write its own row, as before, so nothing is lost to a burst. Shutdown flushes the queue.
- Pruned by
services::retention, not the log writer: one environment at a time, in batches. - The Audit list is ordered by time, the order every index is in. The Person dropdown lists admins and website apps from their own tables instead of aggregating the trail. Copied fields are capped at 2 KB each. A sandbox clone no longer copies the trail at all.
- Left as is on purpose: the search box is an unindexed match. A trigram index would add storage to every row for an occasional search.
docs/progress/2026-10-01.md.
15. The member account page after the journey review — decided 2026-09-28
Owner: Business owner · taken by Thomas, please confirm · 2026-10-14: the cancel (AC5) is settled by #49 — a card membership's cancel ends at the period end with Resume, then free Community, built before cutover
What it is. A review of the WordPress account hub ([fms_account], plugin 0.40.0) against specs 06, 07 and 09. The hub had grown card by card and showed no status at all. One redirect hid the Confirm payment banner from exactly the members who get it: card-paying members were sent from the Membership & Billing page straight to Stripe. Grace, gift end dates, donations and the archive appeared nowhere on it. A seated team member was told to "manage it from your group", a page seats do not have.
Why it matters. After cutover the account page is the one channel every member reliably has, and the member-email decision (§2, MIG-2) was still open then (decided 2026-09-29). Whatever email would announce — a failed renewal, a gift ending, a bank hold — has to be visible on the page.
| # | Decision | Choice | Why (and what was rejected) |
|---|---|---|---|
| AC1 | What the hub shows first | A Your membership header: plan, state, kind, renews/ends date, then any payment banner (bank hold, grace, parked upgrade). | Spec 07 FR-18 always asked for status. The banners already existed on inner pages only. Rejected: a banner strip without the plan (members still could not see what they hold). |
| AC2 | Cards added to the hub | Make a donation, and the newsletter archive card under the grid. Gift recipients get Continue as a member in the header. | Donate is in the design mock. The archive is metric 4. Continue is the only permanent route for a gift recipient while the continuation email is a one-shot. Rejected: a Give-a-gift card (kept on [fms_support] to stop the hub growing). |
| AC3 | The billing-page redirect | Removed. | It hid the bank-hold banner, cross-account invoices and the cancel save-offer from card payers. Its reason (the theme hub could not be edited) is gone. Con: a member on the old theme hub takes one more click to reach Stripe. |
| AC4 | How fresh the header is | Read from FMS once per page load, never kept in a transient. | It carries the payment banners. A cached banner could ask a member who just paid to pay again. Con: one extra FMS call per hub view. |
| AC5 | Self-cancel timing | Cancel at period end, with Resume. Built after cutover. | A member who paid for a year and cancels in month 3 keeps the 9 months they paid for, as under PMPro and in Stripe's own portal. Rejected for now: shipping it before Oct 20, because it changes when Community enrolment happens (it moves to the customer.subscription.deleted webhook) and that path deserves its own live-fire. Until then cancelling ends access at once, and the page says so. |
| AC6 | Scope before cutover | Five fixes + AC1–AC4. | Fits the Sep 29 – Oct 10 slot beside the email layer. Team invite email and one-render-path consolidation stay parked. |
Built 2026-09-28 (AC1–AC4): plugin 0.41.0, MembershipView.team_seat and .gift, /v1/me/access reports past_due during a bank hold, /v1/dashboard/summary removed, /v1/subscriptions drops region. docs/progress/2026-09-28.md.
AC5, to build after cutover: POST /v1/me/membership/cancel {at_period_end} sets Stripe's cancel_at_period_end (stripe::set_cancel_at_period_end, behind the live-write brake); a new POST /v1/me/membership/resume clears it; the webhook comp-enrols Community when a subscription that was scheduled to cancel is deleted; change() refuses while a cancel is scheduled; the header's Ends on … and will not renew line gains a Resume button.
16. One page per job: the theme's old pages, its links, and the plugin's pages — decided and built 2026-09-29
Owner: Technical
What it is. Thomas found two "Membership & Billing" pages (2026-09-29). An audit of every way a member reaches an account surface, on the rehearsal rig's copy of the live site (theme fm, menus, the my-account-block cards, the theme's PHP and JS) and in plugin 0.42.0, found the same pattern in several places. The mechanism: the plugin shims the old PMPro shortcodes ([pmpro_billing] → [fms_billing], [pmpro_invoice] → [fms_invoices], [pmpro_member_profile_edit] → [fms_profile], [pmpro_login], [pmpro_signup], [pmpro_confirmation]), so every old page keeps rendering a working copy at its old URL, while the plugin's own links go to whichever page is picked in Membership pages. The theme's menus, templates and JavaScript still carry the old URLs. Two entry points, two pages.
What was found (theme paths are hard-coded in the un-editable theme unless marked menu):
| # | Job | The theme sends members to | The plugin sends members to | Result today |
|---|---|---|---|---|
| L1 | Membership & Billing | menu tab → legacy Orders page ([pmpro_invoice] + the theme's [show-stripe-subscription-info] panel); the legacy hub card, the Orders page's Cancel my subscription and the cancel button → legacy Billing page ([pmpro_billing]) | the picked page ([fms_billing]) | Three URLs for one job. 0.42.0 retargets the menu tab only, and only once a page is picked. |
| L2 | The Orders page's My Membership panel | reads Stripe directly with the theme's own key (API_KEY_STRIPE_FRONTIER, inc/stripe-get-subscription.php) and PMPro's pmpro_stripe_customerid user meta | /v1/me/membership | After cutover the panel shows the old TH account's view: a member rerouted to SG, or imported without that meta, sees nothing or stale data next to correct FMS invoices. |
| L3 | Profile | menu tab, the theme's change password button and its JS → legacy your-profile (shim) | hub card → picked Profile page (falls back to wp-admin); plugin's own nav → the hub | Two profile pages; three fallbacks. |
| L4 | Join / Log in | /en/register/ (shim), /en/login/ (shim; theme header, logout redirect, JS) | Join picker, Log In picker | Fine when the pickers point at those same pages, as the guide says for Log In. Nothing says so for Join. |
| L5 | Cancel | legacy membership-cancel page holds [pmpro_cancel] | no shim — and the Cancel membership picker is never used to build a link | With PMPro off the page prints the literal text [pmpro_cancel]. Cancelling is reachable only through Membership & Billing. |
| L6 | Change your membership | — | hub card, header See membership options and the team page's Switch to an individual membership → [fms_billing]#membership when a billing page is set but no pricing page | That section shows no button without a pricing page. Dead end. |
| L7 | Old pages that are redirected | legacy account, levels, team, newsletters pages | → picked hub, pricing, team pricing, newsletters pages (pmpro-compat.php) | Covered. The same is not done for Orders, Billing, Profile, Cancel, Confirmation (L1, L3, L5). |
| L8 | Donate | header CTA, footer, legacy hub card → donate.stripe.com | menu items rewritten and a footer script rewrites every anchor → FMS /donate | Covered; noted so nobody "fixes" it again. |
Not a duplicate, but same family: the plugin's hub cards check the picked page is published while fms_mg_pricing_url / fms_mg_signup_url do not; the hub's Newsletters, Membership and Team panels duplicate their standalone pages by design (?fms_panel=).
| # | Decision | Options | Recommendation |
|---|---|---|---|
| P1 | How an old URL reaches the one page | (a) Redirect each remaining legacy page (Orders, Billing, Profile, Cancel, Confirmation) to its picked page, the way account/levels/team/newsletters already are. Legacy page found by the shortcode it holds (fms_mg_pmpro_page_has_shortcode, as checkout does), so no new pickers. Pro: old bookmarks, the theme's hard-coded links and the cutover URL sweep all land right; one place to reason about. Con: five more redirects; a picked page that is the legacy page must stand down (the existing guard). (b) Operator only: put the [fms_*] shortcode on each legacy page and pick it. Pro: no code. Con: relies on nobody ever picking a different page; the Orders page keeps its theme panel (L2) because the shortcode swap does not remove it. (c) Both. | (a), and the guide tells staff (b) is the tidy choice for Membership & Billing. |
| P2 | The Orders page's Stripe panel (L2) | (a) Unreachable once P1 redirects the page. (b) Also override the theme's [show-stripe-subscription-info] with the [fms_billing] facts list, so the page is correct even before a picker is set. | (b) on top of P1: cheap, and removes the last place the website calls Stripe with its own key. |
| P3 | [pmpro_cancel] (L5) | (a) Shim it to [fms_cancel]. (b) Unpublish the page. | (a), and drop the never-used Cancel membership picker from settings (or make the hub's Membership & Billing #cancel the only route, which it already is). |
| P4 | Dead end (L6) | (a) #membership renders the [fms_membership] switch options when no pricing page is set, as its docblock already claims. (b) Refuse to save a billing page without a pricing page. | (a). |
| P5 | Profile fallbacks (L3) | (a) One fallback: the hub, never wp-admin (members must not land in wp-admin, spec 07). (b) Leave. | (a), one line. |
Cost. P1–P5 together are a plugin 0.43.0 of about a day, testable on the rig with the URL sweep in docs/migration-runbooks/new-vps-test-cutover.md. Independent of the SMTP/email work.
Decided 2026-09-29: Thomas took all five recommendations. Built the same day, plugin 0.43.0. P1 as a shared helper (fms_mg_legacy_page_target_url) that the four older redirects now use too; the old Confirmation page is not redirected (it is where a finished checkout lands); only fms_* query arguments carry across. P2 replaces both theme shortcodes on the Orders page. P3 keeps the Cancel picker as the fallback target when no Membership & Billing page is set. P5 falls back to the old PMPro profile page before the hub, not straight to the hub: without a Profile page that is the only front-end place to fix a mistyped address; with neither, only the held member's Wrong address? link may still open WordPress's own profile screen, so a confirmation link that never arrives is never a dead end. docs/progress/2026-09-29.md.
17. Who curates the archive's list of newsletters — decided 2026-09-29
Owner: Business owner · taken by Thomas, please confirm who gets the manager tick
What it is. The newsletter archive groups issues into newsletters ("series") by the Mailchimp sender name, because Frontier's campaigns are not foldered (archive/src/series.ts). Every one-off send under a personal from-name became its own filter chip: editor-at-large Thomas Kean, CEO Aung Htin Aung, journalist Ya Nwe and a dozen more beside the five real newsletters. Only those five had a name and colour in code; everything else showed as sent.
Why it matters. The chip row is the first thing a member sees. Thomas wants to keep series as their own thing but decide himself which ones show, and change that later, without a code change or a deploy each time.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Mailchimp folders | Editorial files campaigns into folders; the archive already prefers a folder over the sender. | No code. | 2,478 past sends to file by hand; every future send needs the discipline. | Hours of filing |
| B. Rules in the archive's code | Aliases, merges and a minimum count in series.ts. | Small change. | Every rename is a code change and a deploy. | Half a day |
| C. A table in the FMS console | A Newsletter series screen in FMS the archive fetches. | Staff tune it without developers. | A migration, API, SPA screen and guide page, two systems apart; the archive talks to FMS only for sign-in today. | 1–2 days |
| D. Edit in the archive, FMS says who (chosen) | FMS reports a new archive_manage capability in the hand-off; members holding it get Manage newsletters inside the archive, saved in the archive's own store. | Editing happens beside the chips it changes; applies live, no rebuild; FMS stays the one place access is decided. | The archive's SQLite becomes a record to back up; the flag reaches the archive at the next sign-in, not instantly. | 1 day |
Who is a manager — two choices, decided the same day:
| Option | Pros | Cons |
|---|---|---|
| WordPress role (administrators, editors) sent with the hand-off | Zero upkeep. | A plugin release; the role would reach only the redeem, not /v1/me/access. |
| Per-member flag in the FMS console (chosen) | One tick, audited in the activity trail, reported identically by every caps surface. | Added by hand. |
Decision: D with a per-member flag, taken 2026-09-29 (Thomas). Also decided:
- A hidden newsletter vanishes entirely for members — chip, issues, counts, articles, search hits and the public teaser — not just its chip. Managers still see it, marked Hidden.
- The first cut offers rename, show/hide, order and colour, colour from a fixed palette.
- The flag also grants
archive_access, so an editor with no paid plan can get in to do the work. It adds no tier and nomembers_only.
Built 2026-09-29. Migration 0060 adds users.archive_manager; services::entitlements::caps_for_member adds archive_manage + archive_access for it, so the redeem, /v1/me/access and /v1/access/can agree. The member page gains a Newsletter archive card (whether the archive lets them in, through their plan or as a manager, and the tick), recorded in the activity trail. The archive stores changes in series_overrides (keyed by series slug, stable across rebuilds) and applies them as it serves the index; GET/PUT /api/series for managers only. No plugin change. Spec 10 FR-14; docs/progress/2026-09-29.md.
Not built, on purpose or for later: merging two senders into one newsletter (a save that would give two newsletters the same name is refused, with that reason); a per-issue override; instant revocation (a removed manager keeps the screen until their archive session ends, 12 hours by default).
Amended 2026-10-01 — the tick moves to the staff account (Thomas). It is a staff power, so it now lives on the staff account, set by an owner on that person's staff page, rather than on the member page. It counts for the website login with that address while the account is active.
| Option | Pros | Cons |
|---|---|---|
| Per-member tick (as built 2026-09-29) | Any member can be made a manager, with no console account. | A staff power hidden on a member page; someone who leaves keeps it until somebody remembers. |
| Tick on the staff account (chosen) | One owner screen for everything a colleague may do; switching them off also ends archive management; no member row needed. | Each editor needs a staff account (Viewer is enough); their website email must match it. |
Migration 0063 carries existing ticks to the staff account with the same address and drops users.archive_manager; a ticked member with no staff account loses the tick. repository::admin::archive_manager_for is the one lookup. docs/progress/2026-10-01.md.
18. The cutover date — open
Owner: Business owner · also: Technical (says whether the gates before it passed)
What it is. The day PMPro is switched off and FMS takes over billing and access. The roadmap proposes a window of 20 to 31 October (re-cut 2026-09-23, "for the owner to confirm or move"). Two gates come first: the go-live session at the Stripe dashboard (webhooks re-provisioned, a live test that includes a team invoice), and a dress rehearsal on the VPS that proves nobody is charged twice and nobody loses access.
Why it matters. Much waits for this date: telling members (#19), the win-back campaign (#2), grant metric 5 (due 30 September, slips to cutover) and metric 1 (measured on the first renewal cycle under FMS). Staff need a day they can plan around, with people free for the 48 hours after.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Keep 20–31 October, go/no-go on 17 October | Pick one weekday in the window. Confirm it on 17 October from the dress-rehearsal result. | October renewals are measured under FMS. The November grant report has a month of data. | Tight. Any slip in the rehearsal moves it. | None extra |
| B. Move to early November | Same gates, two more weeks. | More room for rehearsal and fixes. | Metric 1 is measured on a few weeks only. Cutover support overlaps the handover. | None extra |
| C. No date until the rehearsal passes | Name the day only after the gates. | Never announce a date that cannot hold. | Marketing cannot schedule the member emails. Staff cannot plan leave. | None |
Recommendation. A, a Tuesday or Wednesday morning Bangkok time, not near a print deadline.
After deciding. The date goes into ROADMAP.md, status.json and the migration runbook. Marketing schedules the member emails (#19) against it.
Decision: pending
Note 2026-10-15 (#52). The cutover is no longer one flip. Members move to FMS segment by segment (#52), so this decision now names two kinds of date: the hand-over of new sign-ups and of each segment (proposed in ROADMAP.md), and the day PMPro is switched off once everyone is handed over. Option A's window still fits: the first segment inside it, PMPro off at its end or early November.
Note 2026-10-27 (#52). One hand-over date again. The Business owner reversed the segment schedule: everyone moves on one morning with Hand over everyone (#52, note 2026-10-27). This decision names two dates: T, the morning of the hand-over, and the PMPro-off day at the end of the soak (two to three days later, or the same day if the owner shortens it). The roadmap now plans in days relative to T and offers one proposed calendar — the morning on Tuesday 11 November 2026, PMPro off on Friday 14 November — as a proposal only. The recommendation (a Tuesday or Wednesday morning, Bangkok time, away from a print deadline) stands; the rehearsal of the all-at-once path and the go-live operator session come first.
19. Telling members about the cutover — MIG-13
Owner: Marketing
What it is. No plan exists for telling members that the system changes (RISK MIG-13). What members will notice: new account and billing pages, a new checkout page, card receipts sent by Stripe, and new emails such as welcome and your membership ends soon (#2). Free Community members lose access to 172 posts (#1). What does not change: they log in on the website as before, and card members keep their card and renewal date.
Why it matters. A changed page or an email from a new sender, with no warning, reads as a mistake or as phishing. Support questions then arrive in the cutover week, when staff are busiest.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Say nothing | Members find out by using the site. | No work. | Support load in cutover week. Some members distrust the new emails. | None |
| B. One email before, plus a site banner | A Mailchimp email a week before. A banner on the site during cutover week. | Members know what to expect. | One message only. Members who miss it are surprised. | Half a day of staff time |
| C. B, plus a "what's new" email after, and a help page | A second email after cutover with links to the new account page and the archive. A short help entry. | A second chance to reach members. It can promote the archive (metric 4). | Two sends to the whole list in two weeks. | About a day of staff time |
Recommendation. C, sent from Mailchimp. The Community cohort's email adds one line about the 172 posts. Technical supplies the list of what changes and checks the draft.
After deciding. Marketing drafts. Send dates follow the cutover date (#18). A member-email step goes into the migration runbook.
Decision: pending
20. If FMS stops answering after cutover — MIG-12
Owner: Business owner · also: Technical
What it is. After cutover the website asks FMS which articles each logged-in member may read. It remembers the answer for 5 minutes. If FMS then does not answer (a crash, an update, a network fault), the website today locks the member out of every members-only article until FMS is back. The old fallback, "ask PMPro", stops working once PMPro is off (RISK MIG-12).
Why it matters. A short outage turns into a paywall for every paying member on every article. The opposite mistake opens paid articles to anyone.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Lock out (today) | No answer means no access. | Paid articles are never given away. Nothing to build. | Paying members are locked out during any outage. | None |
| B. Open to everyone during an outage | No answer means the article shows. | Nobody paying is locked out. | Paid articles are free to anyone, even people not logged in, while it lasts. | Small plugin change |
| C. Keep each member's last answer for up to 24 hours | The website keeps using the last answer it had. A member it never asked is still locked out. | Paying members keep reading through a normal outage. Strangers still see the paywall. | Someone whose membership ended in those 24 hours keeps reading until then. | About a day of plugin work |
Recommendation. C. The 24 hours is a suggestion; the owner may pick a shorter window.
After deciding. Built the same day: Member Gate 0.57.0 (docs/progress/2026-10-27-outage-last-answer.md). The outage policy Keep each member's last answer (last_answer) is the new default beside Keep content locked (lock) and Fall back to Paid Memberships Pro (pmpro); the window is fms_mg_last_answer_hours (0–72, default 24; the FMS_MG_LAST_ANSWER_HOURS constant wins). Each authoritative answer is also kept as a per-member copy, served only when FMS does not answer (a transport error, a bad response, a rejected key, a 5xx or 429), never to a visitor, and never across a change of environment, API address or service key. A non-member answer overwrites the copy, so an ended membership locks on FMS's first answer; signing out drops it. A signed-in member with no copy sees "We can't check your membership right now". A readiness row (If FMS cannot be reached, a warning, never blocking) and an admin notice say which policy is active. A site that never saved the setting moves from lock to last_answer with the new default; a stored lock stays and is warned about. Still owed: the rig check (the progress note's seven steps) and the guide's settings screenshot.
Decision: C, 24 hours, taken 2026-10-27 (Business owner). A second answer the same day: a site still set to the old PMPro fallback (pmpro) behaves as last_answer once PMPro is inactive, because falling back to a plugin that is gone must not turn into "lock everyone" — which is MIG-12. While PMPro is active, pmpro behaves as before.
21. A renewal payment fails: keep reading while Stripe retries? — FIN-3
Owner: Business owner · also: Finance
What it is. When a card renewal fails, Stripe tries the card again over the following weeks and emails the member to update it (policy 2026-06-30: Stripe's retries stay on). Meanwhile the member is past due. Today FMS gives a past-due member no members-only articles, while some newsletter paths still treat them as a member, so the two can disagree (RISK FIN-3). Two exceptions are already decided: a renewal waiting for a bank confirmation (3DS) keeps access for a short window, and members already past due on migration day get grace (#11 B).
Why it matters. Most failed renewals succeed on a retry within days: an expired card, a bank being cautious. Locking those members out at once is the payment-related churn the grant wants near zero (metric 1). Keeping access gives up to a month away to the few who never pay.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Stop at once | Articles and newsletters stop on the first failure. | Strict and simple. | A member with an expired card loses access the same day. | Half a day, to make the two paths agree |
| B. Keep everything while Stripe retries, up to 30 days | Articles and newsletters continue. Access ends when Stripe gives up, or after 30 days. | Same rule as migration day. Members have time to update their card. | Up to 30 days free for someone who never pays. | About a day |
| C. Keep everything for 7 days, then stop | A shorter window. A later successful retry restores access. | Less given away. | A member recovered on day 10 has a gap. | About a day |
Recommendation. B.
After deciding. Built 2026-10-27, the day it was decided (below). FIN-3 is closed and ticked on the RISK §5 checklist; it ships with the FMS deploy before the morning.
Decision: B, taken 2026-10-27 (Business owner): "for a failing subscription we retry for 30 days, which means the user is able to read for 30 days." The 30 days is the failed-payment retry schedule set in each Stripe account's own dashboard (the owner's figure; FMS does not set it), ending by cancelling the subscription.
The build (built 2026-10-27, docs/progress/2026-10-27-dunning-grace.md). Until then past_due alone granted nothing, on purpose; the only exceptions were the cutover import, which puts an already-past-due member into dunning grace (status = grace, grace_source = 'dunning'; migration 0048), and Opn renewals, which use the same shape. Now the Stripe webhook that reports a renewal past_due (ordinary dunning: a billing_source = stripe row with no live SCA hold, not a structural reroute) moves the row into dunning grace in the same transaction (services::grace::webhook_local_status, enter_dunning_grace). The deadline (services::grace::dunning_deadline) is the later of the paid-through date — the period start, because Stripe advances the period on a failed renewal, so the period end would hand an annual member the unpaid year — and now + GRACE_PERIOD_DAYS (30). A redelivered or later past_due keeps the first deadline. A successful retry (the subscription back to active) returns the row to active; Stripe's final cancel ends it (canceled, team cascade), and unpaid ends it without the cascade; the grace sweep's local lapse is the backstop, with no Stripe call. The reroute worker still handles a dunning-grace row (a structural failure flips it to structural grace), and a Stripe dunning-grace row blocks a second checkout so the member cannot be charged twice. Adoption and Consolidation's Stripe fixes store a past_due as dunning grace too (import audit #18). Because grace rows count as live in the access set, this closes FIN-3's split (access_rows / active_plan_codes exclude past_due but include grace). Follow-ups: the cutover importer still uses the period end for its deadline (its import shape carries no period start; bounded by Stripe's own cancel after 30 days of retries), and the console member page shows the grace pill but not the grace end date. Before the morning: each Stripe account's retry schedule is 30 days and ends by cancelling, and GRACE_PERIOD_DAYS is 30 on the server (runbook The day, step by step, T-7).
22. Deleting a member's data on request — SEC-10
Owner: Legal / compliance · also: Technical
What it is. A person may ask Frontier to delete their personal data (GDPR article 17 for people in the EU, Thailand's PDPA). FMS has no delete. A member's data sits in FMS, two Stripe accounts, Mailchimp, WordPress, the logs and the backups. Two related gaps in the same finding (RISK SEC-10): the console's database download is not encrypted, and because FMS shows the card form itself, the yearly PCI self-assessment is the longer SAQ A-EP form, with nobody named to complete it.
Why it matters. A request has a legal deadline, one month under GDPR. Without a procedure staff improvise and miss a system. Not everything may be deleted: payment records are usually kept for tax, so Legal must say what stays and for how long.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. A written procedure now | A runbook page: each system, what to delete, what to keep, who does it. | Ready before the first request. No code. | About an hour of manual work per request. | Half a day, plus Legal's review |
| B. A console button later | FMS removes the name and email, keeps the payment amounts, and queues the Mailchimp and Stripe deletions. | Fast and consistent per request. | Build time. WordPress and backups still need a manual step. | 3–4 days |
| C. Wait for the first request | Nothing now. | No work. | A legal deadline starts the day it arrives. | None |
Also for Legal: how long payment records are kept, whether the database download must be encrypted (Technical: half a day), and who completes the PCI form each year. Added 2026-10-05 (from #6): staff write free-text notes about a member on the console (member_notes, append-only, with the author and time). They are personal data about that member: a copy request includes them, and Legal says whether they go with the member on deletion or are kept (for example a note recording why a free membership was given).
Recommendation. A now, with the encrypted download. B after the grant if requests arrive.
After deciding. A: a section in ops/RUNBOOK.md and a line in the privacy notice.
Decision: pending
23. Newsletters sent to a shared inbox: who may unsubscribe — open
Owner: Legal / compliance · also: Marketing
What it is. A member can have their members-only newsletters delivered to another address, for example a shared office inbox. That address confirms once when it is set. After that, the people reading that inbox have no way to stop the newsletters through FMS: only the member can, from their account (RISK, shared-address opt-out).
Why it matters. Anti-spam rules (CAN-SPAM, GDPR) expect whoever receives an email to be able to stop it.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. The unsubscribe link always wins | Unsubscribing at that address stops delivery there. The member is told and can choose another address. | Meets the rule. | A colleague can stop a member's delivery without asking. | Half a day to a day |
| B. Only the member decides (today) | The inbox can only ask the member. | No work. | Compliance exposure. | None |
Recommendation. A.
Decision: pending
24. Free newsletters for new sign-ups: ticked or unticked — open
Owner: Marketing · also: Legal / compliance
What it is. The sign-up form has no newsletter box. Joining the free Community plan switches its newsletters on, and the member can switch them off later on My Newsletters (spec 03 Q6). Nobody has decided whether that is the rule, or whether people who are not members should be offered the free newsletters at all.
Why it matters. Automatic sign-up grows the list, but some readers count it as unasked-for mail. Under GDPR, consent should be a clear choice. Mailchimp also bills per contact.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Keep automatic, say so on the form | One line under the button: "You will receive our free newsletters. Unsubscribe any time." | Largest list. Smallest change. | Weaker consent for EU readers. | An hour |
| B. A box on the form, ticked | Readers see the choice and can untick it. | A visible choice. Most people leave it ticked. | A pre-ticked box is not valid consent under GDPR. | Half a day |
| C. A box on the form, unticked | Only people who tick it get the newsletters. | Clean consent. More engaged list. | A smaller list. | Half a day |
Recommendation. A if Legal agrees for the audience Frontier has; otherwise C.
Decision: pending
25. Team prices at 16 and 17 seats, and the largest team — decided 2026-10-14
Owner: Finance · also: Business owner
What it is. The team price list (per year, 3 to 19 seats) came from notes that skip 16 and 17 seats. FMS uses prices inferred from the pattern: $3,024 for 16 seats and $3,094 for 17. The notes also disagree on the largest team: they list up to 19 seats, and say the discount runs "till 18" (spec 01 Q1, PRD Q7).
Why it matters. A team of 16 or 17 is charged a price nobody approved. Once a price exists in Stripe, FMS refuses to edit it locally (FIN-1), so it should be right before cutover.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Confirm both prices and a 19-seat limit | Larger teams use the contact form and a team invoice. | Nothing changes. | — | None |
| B. Give the correct prices | Finance supplies the two totals and the limit. | Prices are approved, not guessed. | — | An hour |
| C. No limit | Any size at $175 a seat. | Large organisations can buy online. | No room to negotiate a big deal. | Half a day |
Recommendation. A or B, before the rehearsal mirror.
Decision (Business owner, 2026-10-14): the prices are correct, and there is no largest team. $3,024 for 16 seats and $3,094 for 17 are confirmed; from 18 seats the price is $175 a seat with no upper limit — 18 → $3,150, 19 → $3,325, 20 → $3,500, 21 → $3,675, and so on. That is exactly the formula services::team_pricing already encodes (per seat $280 − $7 per added seat, floor $175), so no price changes; the last two places that still capped teams at 19 (team-invoice requests and seat upgrades, services::team_invoices) now use the ladder's own bounds, and the online ladder offers 3 to 100 seats (MAX_SEATS); a larger team asks for an invoice. Spec 01 Q1 and PRD Q7 are closed.
26. What members may do in Stripe's billing portal — open
Owner: Business owner · also: Technical (sets it on both Stripe accounts)
What it is. Manage billing opens Stripe's own page for the account that bills the member. What members can do there is set in each Stripe dashboard: update the card, see invoices, cancel, switch plan (spec 06 Q1).
Why it matters. A cancel or plan switch made on Stripe's page skips FMS's own pages: the save-offer, the team rules, the cancellation email. FMS only learns of it afterwards.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Card and invoices only | Cancel and plan changes stay on the website. | One route for each change, with FMS's rules applied. | Members who look for Cancel on Stripe's page do not find it there. | A dashboard setting |
| B. Also allow cancelling | Stripe's page can cancel. | Easy to leave. | Skips the save-offer and the website's wording. | A dashboard setting |
| C. Everything | Cancel and plan switch on Stripe's page. | Least friction. | Plan rules, team seats and routing can be bypassed. | A dashboard setting |
Recommendation. A.
After deciding. Technical sets both accounts at the go-live session.
Decision: pending
27. Two memberships at once, and more than one team admin — open
Owner: Business owner
What it is. Two rules nobody wrote down (spec 06 Q8, Q9). First: may one member hold two memberships? It happens today, for example a team seat beside a personal plan, and Consolidation flags other doubles as duplicates. Second: a team has one admin, who can hand the role over. Should a team have several?
Why it matters. The answer decides what the account page shows and what Consolidation calls a mistake. Large institutions often want more than one person managing seats.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. One personal membership, one team admin (today) | A team seat beside a personal plan is fine. Other doubles are mistakes. The admin role can be handed over. | Matches what is built. | An institution's office manager and director cannot both manage seats. | None |
| B. As A, but several team admins | A team can name more than one admin. | Fits institutions. | — | 2–3 days |
Recommendation. A until a team asks for a second admin.
Decision: pending
28. Reading statistics in the newsletter archive — open
Owner: Marketing
What it is. The archive shows every member each newsletter's open rate and a Most read list (spec 10 G-14). This was never chosen on purpose.
Why it matters. Open rates are internal numbers. A low one can make a newsletter look unpopular to the readers it is meant for.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Keep both | No change. | Readers find popular issues. | Internal numbers are public to members. | None |
| B. Keep Most read, hide open rates | — | Readers still find popular issues. | — | An hour |
| C. Hide both | — | Nothing internal is shown. | Less help finding good issues. | An hour |
Recommendation. B.
Decision: pending
29. Archive pictures load from Mailchimp's servers — open
Owner: Legal / compliance · also: Technical
What it is. Pictures in archived newsletters load from Mailchimp's servers (spec 10 G-15). So Mailchimp sees each reader's internet address, and a picture disappears if Mailchimp deletes it. Links are not part of this question any more: since 2026-10-01 no link in the archive leads to Mailchimp's public copy of an issue (spec 10 FR-15). Only pictures remain.
Why it matters. Readers' addresses reach a third party. Old issues may lose their pictures.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Accept, and name Mailchimp in the privacy notice | No change to the archive. | No work. | Pictures may still vanish one day. | A notice line |
| B. Copy the pictures to Frontier's server | The archive keeps its own copy. | Nothing reaches Mailchimp. Pictures stay. | Storage and backup to look after. | 1–2 days |
Recommendation. A now, B after the grant.
Decision: pending
30. A campaign to turn free members into paying ones — open
Owner: Marketing · also: Business owner (the discount)
What it is. The grant plans acquisition campaigns in October, aimed at free Community members and social channels (Track 2), and a 30% rise in institutional members through one-time special rates. Nothing is planned yet. The tools exist: the Community tag in Mailchimp, discount codes, and a link that opens checkout with the code filled in.
Why it matters. It is the fastest route to grant metric 3, member revenue up 15–20% by November.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. A Mailchimp campaign to all Community members | One offer, for example a first-year discount, with the checkout link. | Reaches everyone. No developer needed. | Many readers never open it. | About a day of staff time |
| B. As A, to the most engaged readers only | A Mailchimp segment of people who open often. | Better conversion. Fewer people mailed. | A smaller audience. | About a day of staff time |
| C. Wait until after the grant | — | Less load during cutover. | Metric 3 relies on other growth. | None |
Also: what the offer is, and a separate offer for institutions.
Recommendation. B, sent in the second week after cutover, once the site is settled.
Decision: pending
31. Monthly donations — open
Owner: Business owner · also: Finance
What it is. Donations are one-off only (decided 2026-08-10, spec 06 Q5): a monthly donation would be a second kind of subscription that every rule would have to learn to ignore. docs/GROWTH-IDEAS.md ranks monthly donations as the largest unused source of income.
Why it matters. Regular donors give more over a year than one-off donors.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Stay one-off | No change. | No risk to membership rules. | Regular giving is not offered. | None |
| B. Monthly donations in FMS | A monthly option on the donate page. | In FMS reports. Card routing works. | A second kind of subscription to test everywhere. | 3–5 days |
| C. A Stripe payment link, outside FMS | Stripe's own monthly link. | No build. | Not in FMS reports. No AMEX on the TH account. | An hour |
Recommendation. A for this grant period. Revisit in Phase 2.
Decision: pending
32. Alerts when Stripe or Mailchimp stop answering — MIG-8
Owner: Technical
What it is. The health monitor checks FMS itself. It does not check that each Stripe key and the Mailchimp key still work, or that Stripe's webhooks still arrive (RISK MIG-8). A revoked key raises no alert. A daily payment-health check and Consolidation's webhook check cover part of it.
Why it matters. A silent failure is exactly what FMS exists to prevent.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Build the checks before cutover | Per-account Stripe and Mailchimp checks plus a webhook-arrival check, debounced, wired to alerts. | Closes MIG-8. | Takes time from the October slot. | About 2 days |
| B. Accept the daily check until November | Build after cutover. | Keeps October for cutover work. | A fault can go unnoticed up to a day. | None now |
Recommendation. B, unless the October slot has room.
Decision: A, taken 2026-10-02 (Thomas): build the per-account Stripe, Mailchimp and webhook-arrival checks before cutover. Nothing goes on the sign-off sheet. Built 2026-10-02 (services::integration_health). The rows are not critical: a dead connection alerts staff but does not turn the public status probe 503.
33. PMPro's membership-manager role and delayed starts — MIG-3
Owner: Business owner · also: Technical (checks the live site)
What it is. Two PMPro add-ons have no FMS replacement (RISK MIG-3): a membership manager role (staff who manage members in WordPress without being full admins), and subscription delays (a membership whose first payment starts later, like a free trial). It is not known whether either is used on the live site.
Why it matters. If either is used, it stops silently when PMPro is switched off.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Confirm unused, and accept | Technical checks the live site. Nothing else. | No work. | — | An hour |
| B. Used: move to FMS tools | Those staff get FMS console accounts. Delayed starts become a discount code or a free grant. | Nothing is lost. | Staff learn a new screen. | Half a day |
Recommendation. Check first, then A or B. Since 2026-10-01 (#37) option B's console accounts exist: since 2026-10-10 such staff get the Membership manager role on Staff (Support and Finance in one account).
Decision: B, built 2026-10-24 at the owner's request, so either answer to the live-site check is covered. The membership-manager half is the Membership manager role (2026-10-10). Delayed starts are a plan setting, not a discount code or a staff grant: First payment after (days) on an individual paid plan (plans.delay_days, migration 0096, spec 05 FR-13b). A new card member gets access at once and pays first on day N (Stripe trialing until then). Sold through Stripe only: an Opn account never takes a delayed plan, because FMS drives Opn renewals itself and has no trial to carry; a card only Opn takes is told to use another. A plan change, a grant, a gift and a team never carry it. Member Gate 0.56.1's readiness row Subscription delays lists the PMPro levels that wait, so the matching plans get the same number. Still open: the live-site check (Thomas), which says whether either is needed on the day.
34. Three old Stripe prices that match no plan — open
Owner: Finance · also: Technical (supplies how many members are on each)
What it is. When the live Stripe subscriptions were matched to FMS plans (rehearsal, 2026-08-29), three prices matched none: one carries a permanent discount, and two are old prices left unmatched on purpose (#5). Members on them keep paying the old amount in Stripe. FMS needs to know which plan each of them holds.
Why it matters. An unmatched member may get the wrong access or newsletters after cutover.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Match each to the nearest plan, keep the price | Members see no change. | No member contact needed. | Old prices live on. | An hour |
| B. Match, and move to today's price at renewal | Members pay the current price from their next renewal. | More revenue. | Members must be told first (Marketing). | An hour, plus a notice |
| C. Leave unmatched | Staff handle them by hand from Consolidation. | — | Manual work forever. | None now |
Recommendation. A.
After deciding. The choice goes into the rehearsal price map before the mirror (Phase B).
Decision: pending
35. What a Friend or Follower reads in the archive — decided 2026-10-01
Owner: Business owner · also: Marketing (which newsletters to open) · taken by Thomas, please confirm; which newsletters each tier opens is still open
What it is. #13 made the archive a per-plan tick: a plan either opens the whole archive or none of it. Thomas wants tiers to matter inside it: Fanatic and team members read everything; Follower and Friend read only the newsletters meant for them. Mailchimp tags already sell the newsletters by tier (Follower adds the Daily Briefing, Fanatic adds the Media Monitor), so the archive should be able to follow the same ladder.
Why it matters. Without this, ticking Friend or Follower in the console (#13, still open) hands over every Fanatic newsletter's back issues, so the box stays unticked and metric 4 stays partly true.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. All or nothing per plan | Today (#13). | No work. | A Friend gets the Media Monitor archive or no archive at all. | None |
| B. A rule per newsletter, Fanatic by default (chosen) | On Manage newsletters each newsletter gets Who can read: Fanatic members only, Followers and up, or Everyone with the archive. | Managers change it live, beside the chips it affects; uses the tier FMS already sends; safe default. | A newsletter is one rule: a single free issue of a Fanatic newsletter needs option C. | 1 day |
| C. A rule per issue as well | B, plus opening or locking one issue. | Finest control (a free sample issue). | A bigger screen and data model for a need nobody has yet. | 2–3 days |
What a member below the rule sees — two choices, decided the same day:
| Option | Pros | Cons |
|---|---|---|
| Headlines with a lock and an upgrade card (chosen) | The archive sells the upgrade (Track 2); the member knows what exists. | Names a tier in the archive's copy. |
| The newsletter is invisible, like Hidden | Simplest; no tier named. | A Friend never learns the Daily Briefing archive exists. |
Decision: B, with locked headlines, taken 2026-10-01 (Thomas). Also decided:
- The default is Fanatic members only. A newsletter nobody has opened, including one the archive detects for the first time, stays with Fanatic and team members. Ticking Friend or Follower in the console is therefore safe at once: they see headlines until a manager opens a newsletter to them. This answers #13's open question's risk, not the question itself.
- Managers read everything. A member with archive access but no tier FMS knows (an imported plan) reads what a Friend reads.
- Locked means: the chip and every issue title, standfirst and picture show, marked with a lock and the tier that opens them; the excerpt, the article and every search hit do not. The public teaser is unchanged (titles only).
Built 2026-10-01, in the archive only: series_overrides.audience, applied as the index is served. FMS needed no change, because the redeem already sends tier, Fanatic for team members. The archive server must check archive_access (ARCHIVE_REQUIRED_CAP); one still on tier_fanatic never lets a Friend or Follower in. Spec 10 FR-16; docs/progress/2026-10-01.md.
After deciding. Marketing proposes which newsletters open to whom, matching the Mailchimp tag ladder (services::tag_catalog): for example the Daily Briefing to Followers and up, the Political Insider and Frontier Backstage to Everyone with the archive. A manager sets it on Manage newsletters. Then the business owner ticks Friend and Follower on the Plans screen (#13).
36. How often an open team invoice is re-checked with Stripe — open
Owner: Technical · also: Finance (for information: under B a missed webhook shows on the Team invoices screen up to an hour late, 2026-10-05)
What it is. The team-invoice sweep runs every minute. For every request that is open, confirming, canceling or queued, it queues a sync job unless one is already waiting. Each job reads the request's invoice from Stripe. So each open team invoice costs one Stripe call and one outbox row every minute, about 1,440 a day, for as long as it stays unpaid. Webhooks (invoice.paid and the rest) already tell FMS when an invoice changes. Found while making the logs and audit cheaper (2026-10-01). The completed rows now age out after 180 days, and the sweep's "already waiting?" check got its own index, but the volume is unchanged.
Why it matters. Ten open invoices for a month is about 430,000 outbox rows and Stripe calls. That is table growth, backup size, and Stripe rate-limit budget spent on "nothing changed".
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Keep every minute | No change. | A missed webhook is caught within a minute. | The volume above. | None |
| B. Every hour, and at once on a webhook or an admin action (recommended) | The sweep re-checks a request only if its last sync is over an hour old. Webhooks and console actions still queue a sync straight away. | ~60× fewer rows and calls. A missed webhook is still caught within an hour. | A missed webhook shows up to an hour late on the Team invoices screen. | Half a day |
| C. Daily, plus webhooks and actions | As B, once a day. | Fewest calls. | A missed webhook can sit a day. | Half a day |
Recommendation. B. It keeps a safety net for missed webhooks at a cost close to zero.
Decision: pending
37. Who can do what in the console — decided 2026-10-01
Owner: Technical · also: Business owner (who gets which role, and whether Support may give a comp), Finance (what Finance may do) · taken by Thomas; the Business owner is asked to please confirm the split of powers (Decision board card 37)
What it is. Until now the console had one kind of account: whoever could sign in could do everything, from noting a phone call to switching the whole server to a sandbox or restoring a backup. There was no screen to add a colleague; the only account was the one made from ADMIN_EMAIL on first boot. Spec 05 left "who is an admin" open (FR-20, Q5) and parked roles as [Later]; #14 reserved a column for them. Thomas wants several staff on the console with different powers.
Why it matters. Shared sign-ins mean the audit trail cannot say who did what (#14), and a support colleague one click away from Switch environment or Restore is a cutover risk. A role per person fixes both.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. One shared owner account | Today. | No work. | No accountability; everyone can do everything. | None |
| B. Four fixed roles (chosen) | Owner, Finance, Support, Viewer; an owner gives each person one. | Easy to explain and audit; one table in the code decides every route; fails closed. | A colleague who needs "Support plus one money button" gets Finance or asks. | 2 days |
| C. Per-permission checkboxes | Each account ticks what it may do. | Finest control. | Hard to reason about, easy to get wrong, a bigger screen. | 4–5 days |
Decision: B, taken 2026-10-01 (Thomas). What each role can change (everyone reads every screen and sees Settings → Audit):
| Role | Can change |
|---|---|
| Owner | Everything, and only owners: staff accounts (Staff, with each person's archive tick), connections and the Stripe importer, service keys, sandboxes (create, switch, reset, delete), backup and restore, email and archive settings, plans and the catalog tools, dead-letter replay, testing and rehearsal tools, the member bulk import, Consolidation fixes and the emailed report, Logs. |
| Developer (added 2026-11-07) | The machinery (Permission::Technical, shared with Owner): connections and the Stripe importer, payment routing, service keys, sandboxes (create, switch, reset, delete, workers, compare with production), backup and restore, email and archive settings, Logs, dead-letter and webhook replay, testing and rehearsal tools; sees Health and its Failed updates tab. Reads everything else like a Viewer. No member, money, staff account, plan, catalogue copy to production, Claude switch or hand-over change. |
| Membership manager (added 2026-10-10) | Everything Support and Finance can change, plus two Mailchimp-side Consolidation fixes: re-sync a member's drifting tags, and strip paid tags from non-members. Nothing else that is an owner's. |
| Finance | Money: record a payment, renew a grant, stop a membership, change the external method, delete a ledger row, the fee backfill, invoice backfill on a member, new teams, seats, cancel a team, team invoices (settings, confirm, verify, cancel), discount codes, donation presets. |
| Support | Member and team care, no money: new members (including a comp or external grant), notes, name, email, newsletters, newsletter delivery, archive manager, a grant's access dates, team members, team name, billing email, team admin, team newsletters, re-issuing a gift link, outreach status. |
| Viewer | Nothing. Reads and downloads. |
Also decided:
- Fail closed. One list,
admin::roles::ROUTE_PERMISSIONS, names every/admin/apiroute; a route it forgets is owner-only, and a unit test reads the router so a new route cannot ship without an entry. An unknown role in the database reads as Viewer. - Instant effect. The gate reads the person's row on every request, so a role change or a switch-off bites on their next click, not when the 24-hour cookie expires.
- Refusals are on the trail. A refused request is a
refusedrow naming who tried and their role; the console tells "your role can't" (code: forbidden_role) from a wrong password. - Exports. Every role downloads the Reports CSVs and the Consolidation export (the screen's data as a file). The backup download is owner-only: it holds the whole database, password hashes included.
- Logs are owner-only (raw request text and member ids); Audit is open to every role. Payment routing is read only on Connections, so it is owner-only too.
- Temporary passwords, no invite email. An owner types or generates one and tells the person another way. Until they choose their own, only Your account works. Works without an SMTP relay. Amended 2026-10-01: after Add or a reset, the owner may press Email sign-in details to send the console address, the email and the temporary password in one message. Owner-pressed, never automatic; the server mails only the password it has stored, and only while it is still temporary, so the email stops working at first sign-in. Without a relay or
PUBLIC_BASE_URLthe button refuses and the owner tells the person themselves, as before. - Switched off, never deleted. Activity rows, notes and ledger rows name the account's id.
- Nobody locks the console. No one changes their own account on Staff, and the last active owner cannot be demoted or switched off (checked inside the
UPDATE, under one advisory lock, so two owners acting at once cannot both slip past). - Every existing account became an Owner, as does the
ADMIN_EMAILbootstrap. - A restore rewinds staff accounts and roles, because they live in the boot database.
- Amended 2026-10-10: a fifth role, Membership manager. Frontier's membership manager (the job named on #41 and EM9, and the person PMPro's membership-manager add-on existed for, #33) does member care and member money every day, plus the Mailchimp tag hygiene that only an owner could apply on Consolidation. Owner hands that person sandboxes, backups and connections; Support or Finance loses half their work; the old answer ("gets Finance or asks") costs a second account. The new role holds everything Support and Finance do, plus the two Mailchimp-side Consolidation fixes (
POST /admin/api/reconciliation/tags/fixand…/mailchimp-reverse/strip). The Stripe-side repairs, double-billing, orphaned teams, the level and invoice backfills, Email report, the Mailchimp tag import and the connection itself stay owner-only. Through Claude it reads the decision board like Finance; the risk board stays an owner's. Built 2026-10-10: migration0076(theadmin_users.roleCHECK takesmembership_manager), a new route tierPermission::MailchimpFixcarried by exactly those two routes, the console's role picker and Who can do what;docs/progress/2026-10-10.md. - Amended 2026-11-07: a sixth role, Developer, and the Technical tier. The person who keeps FMS running (keys, sandboxes, rehearsals, backups, the mail relay, replaying a failed update) needed Owner for all of it, and Owner also hands over staff accounts, plans and every member and payment. The machinery is now its own tier,
Permission::Technical(owner + developer), and a Developer holds only that tier plus every read. Moved from Owner to Technical: every/connections/**route (Stripe, Opn, Mailchimp — wipe and the merge field included — account priority/enabled/emails, the Stripe importer, seed/cleanup/sync-products/map-price, Opn pricing),GET /routing, dead-letter and webhook replay, the Testing console, seeding, scenarios and rehearsal,/environments/**except the catalogue apply (compare is Technical), Logs, service apps and keys,/backup/**,/settings/archive*and every/settings/email/**route. Stay Owner: staff accounts, plans and the catalog tools, the Mailchimp tag import, the Stripe-side and other owner Consolidation fixes, the invoice and level backfills, the emailed report, the member bulk import and newsletter opt-outs,settings/ai(the per-environment Claude switch) andenvironments/{name}/promote/apply, because it changes production's catalogue. Health and its Failed updates (Dead letters) tab are now shown only to developers and owners: they are the machinery's screens, and the replay buttons on them already were. (GET /admin/api/healthandGET /admin/api/outbox/deadstay readable by every role; the Dashboard reads them.) The Migration page is visible to developers too; its hand-over buttons stayPermission::Migration. Through Claude a Developer reads the changes and the roadmap like a Viewer. A developer is not an owner for the last-owner guard. Accepted risk (RISK-BOARD SEC-21): backup export and restore are Technical, and a restore brings back staff roles as they were in the dump, becausecredential_revocationsjournals credential changes, not role changes, so a developer who restores an older backup could regain a role they once held. Accepted because every restore is an audited, visible action and only owners change roles; the fix (journal role changes incredential_journal) is a listed follow-up. Built 2026-11-07: migration0099(theadmin_users.roleCHECK takesdeveloper),Permission::Technical, the SPA'stechnicalpower;docs/progress/2026-11-07-developer-role.md.
Built 2026-10-01: migration 0062, src/admin/roles.rs (the gate), src/admin/admins.rs (Staff), tests/admin_roles.rs; staff guide docs-site/admin/ops/staff.md; docs/progress/2026-10-01.md.
After deciding. The business owner looks down Staff and lowers anyone who does not need Owner. If PMPro's membership managers are in use (#33), they get Membership manager.
38. Who defines the archive's Who can read choices — decided 2026-10-02, superseded by #39
Superseded the same day by #39 (below): reading rules moved out of FMS into the archive's reader groups.
plans.archive_levelwas dropped (migration0065). The text below is the record of what #38 decided and built.
Owner: Technical · also: Business owner, Marketing (which newsletters each level reads) · taken by Thomas · owner corrected 2026-10-03: where a rule is stored is a technical question
What it is. #35 gave each newsletter a Who can read rule, but the choices were three fixed words in the archive's code: Fanatic members only, Followers and up, Everyone with the archive. FMS told the archive only a tier name from a fixed ladder (best_tier). A plan added in FMS could never be a choice: no Friend and up, no Patron only, and an imported plan with no known tier silently read as a Friend.
Why it matters. The plan catalogue is data an admin edits; the archive's ladder was code. Any new or renamed membership needed a code change in two projects before the archive could follow it.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. FMS plans carry a reading level (chosen) | Each plan has a number on the Plans screen. The archive fetches the levels in use and offers one choice per level, plus Everyone. | One place says who gets what. A new plan becomes a choice by itself. Access is a number, so a rename never changes who reads. | Plans that should read the same must share a number. | 1–2 days |
| B. Archive managers build the choices | A screen in the archive: name a box, tick the FMS plans it includes. | Any mix of plans in a box. | Who gets what lives in two places. A new FMS plan reads nothing until someone edits every box. The archive needs the plan list and plan ids from FMS anyway. | 2–3 days |
| C. Both | A's levels, plus custom boxes in the archive. | Most flexible. | Most to build and explain, for no need anyone has. | 3–4 days |
Decision: A, taken 2026-10-02 (Thomas). Also decided:
- Community still never reads (#13 stands). It can't have a level, and Everyone with the archive still means every member who holds
archive_access. - Team plans read like Fanatic. They start on Fanatic's level and carry it across a team-ladder rebuild, like the archive tick does. An admin may move them.
- The default stays a number. A newsletter nobody set is for level 3 (Fanatic and teams), as in #35. Adding a level above Fanatic does not move it; an archive manager can, on Manage newsletters. Everyone can never be the default, so a newsletter the archive finds for the first time is never opened to all.
- A plan with no level reads only newsletters opened to everyone. A code no plan row derives (an old subscription) keeps its old tier's level.
Built 2026-10-02: migration 0064 (plans.archive_level, backfilled Friend 1, Follower 2, Fanatic and teams 3), entitlements::best_level, GET /v1/archive/ladder, level in the hand-off redeem; the archive's src/ladder.ts, series_overrides.min_level, archive_settings. Spec 10 FR-16; docs/progress/2026-10-02.md.
After deciding. Nothing changes for members until someone acts: today's three levels match the three old choices. To add a membership, create the plan and give it a level; the choice appears on Manage newsletters within ten minutes, or at once when the screen is opened.
39. Where the archive's reading rules live — decided 2026-10-02, superseded by #54
Superseded on 2026-10-20 by #54 (below): the archive reads by newsletter. A member reads a series when one of their plans includes its newsletter on FMS's Plans screen; reader groups are gone from Manage newsletters (their
reader_groupsrows andseries_overrides.group_idare kept, unread, for a rollback). #39's principle stands: FMS owns permissions, the archive owns its rules. The text below is the record of what #39 decided and built.
Owner: Technical · also: Business owner (confirms the reading rule, #35; and, since the 2026-10-05 review, that a new plan reads nothing until a manager ticks it into a group — please confirm, Decision board card 39), Marketing (which newsletters each group reads) · reopens #38 · owner corrected 2026-10-03: where a rule is stored is a technical question
What it is. #38 put a reading level on every FMS plan and had FMS publish a ladder, so FMS decided how far up the archive each plan read. That is the archive's business rule living in FMS. The principle the hand-off redeem already followed for archive_access is: FMS owns permissions (whether a plan opens the archive at all, who manages it, which plans a member holds) and an app owns its own rules about what those plans mean to it.
Why it matters. Every rule an app keeps in FMS is a column, a console field and a migration in FMS, and a change in two projects whenever the app's idea changes. Archive managers also wanted to name the choices themselves and pick plans for them, which a number per plan could not do.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Keep #38 | FMS plans carry a reading level; the archive draws one choice per level. | Built. A new plan becomes a choice by itself. | An archive rule in FMS. Names come from plan names. Plans that read the same must share a number. | none |
| B. Reader groups in the archive (chosen) | On Manage newsletters, managers make groups: a name, what locked issues say, the FMS plans ticked from FMS's plan list, and every team plan. Each newsletter gets a group, Everyone, or the default group. | FMS keeps only permissions. Managers name the groups and mix any plans. | A new plan reads nothing until a manager ticks it in a group. | 1–2 days |
Decision: B, taken 2026-10-02, reopening #38 the same day. Also decided:
- FMS says which plans a member holds, never what they read. The hand-off redeem answers
{email, tier, caps, plans};plansis every plan the member holds that confers membership, by code, with its name and kind.tierstays one release for an archive from before groups.GET /v1/plans/alllists every membership plan by code, retired ones too (no auth, names and flags only, like/v1/plans).plans.archive_level,/v1/archive/ladderand the console's Reading level in the archive are gone (migration0065). - Groups hold plan codes, not ids. A subscription carries the code, and Rebuild team pricing gives team plans new ids under the same codes. Every team plan is one switch for the same reason: the seat ladder changes.
- A group is a list, not a ladder. "Follower and up" is a group with Follower and Fanatic ticked. Plans ticked yearly and monthly are separate codes and are ticked separately.
- New plans are not added to any group by themselves. The Reader groups tab warns when a plan that opens the archive is in no group; its members read only Everyone newsletters until a manager ticks it. An automatic rule would widen access whenever an admin made a plan for an unrelated reason, against #35's fail-closed default.
- Unchanged from #35/#38: Community never reads (#13). The default is a group, never Everyone. Managers read everything. Locked means the headline shows and the text, the article and search hits do not.
- Today's rules carry over exactly. The archive turns its saved levels into groups that read the same, by code: Fanatic (Fanatic yearly and monthly, every team plan), Follower (+ Follower), Friend (+ Friend), the default staying Fanatic. Their locked-issue wording is today's ("Included with Follower membership and above"). A level only a plan outside those three used becomes an empty group named Level N, locked until a manager ticks plans.
Built 2026-10-02: FMS services::member_plans (directory, held), GET /v1/plans/all, plans in the redeem, migration 0065; the archive's src/groups.ts, src/catalog.ts, reader_groups and series_overrides.group_id (converted on the first writable open), the Reader groups tab on Manage newsletters. Spec 10 FR-16; docs/progress/2026-10-02.md.
After deciding. Nothing changes for members on the day: the converted groups read exactly as the levels did. To give a new membership its own newsletters, create the plan in FMS with the archive ticked, then on Manage newsletters → Reader groups make or edit a group and tick it.
40. Who may move a website login onto a member (MIG-7 follow-up) — decided 2026-10-03
Owner: Business owner · taken by Thomas, please confirm
What it is. The member page's Website account card (MIG-7) moves a website (WordPress) login from one member to another. The login then reads with the new member's plans. The code review of 2026-10-03 found that Support could move any website account, not only the one that had just tried to sign in. A Support user could move their own login onto a paying member and read as them.
Why it matters. Moving a login hands over a member's paid access. It should be rare and traceable.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Support settles the conflict on the page; owners move anything (built) | Support may move only the website account that tried to sign in with this member's address. Any other move is owner-only. Both ids, and the account the member loses, are in the activity trail. | Support can still fix the everyday case. A takeover needs an owner. | An owner is needed for unusual moves. | Done |
| B. Owners only | Support sees the card but cannot move anything. | Simplest rule. | Every locked-out member waits for an owner. | Small |
| C. As before | Support may move any website account. | Fewest steps. | One Support account can give anyone a paid membership. | None |
Decision: A, taken 2026-10-03 (Thomas), recommended option built. The business owner may overrule. Built 2026-10-03: services::wp_identity::repair takes the repairer's role; the activity row's details carry replaced_wp_user_id. docs/progress/2026-10-03.md.
41. What the Mailchimp clean-up removes from non-members (FIN-10 follow-up) — decided 2026-10-03
Owner: Marketing (membership manager) · taken by Thomas, please confirm
What it is. Consolidation's Mailchimp contacts FMS does not know section (FIN-10, MIG-4) finds people in Mailchimp who carry a paid newsletter tag but are not FMS members, for example someone whose PMPro membership ended years ago. Strip removes their paid tags. The code review of 2026-10-03 found it removed every newsletter tag, including the free Frontier Fridays and Website tags a reader may have chosen in Mailchimp's own form.
Why it matters. A free reader would silently stop getting the free newsletter.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Remove paid tags only (built) | Free newsletters, and the subscriber tag, stay as they are. | Nobody loses what they signed up for themselves. | A non-member keeps free tags FMS did not give them. | Done |
| B. Remove every newsletter tag | The old behaviour. | Mailchimp holds only what FMS gives. | Free readers lose Fridays without asking. | None |
Decision: A, taken 2026-10-03 (Thomas), recommended option built. The membership manager may overrule. Built 2026-10-03: the strip's resync carries strip_members_only, so the CRM worker pushes only the members-only tags. docs/progress/2026-10-03.md.
42. Replaying old Stripe events and the payment-confirmation window (FIN-9 follow-up) — decided 2026-10-03
Owner: Technical · also: Business owner and Finance, for information · owner corrected 2026-10-03: option B left a live late delivery open, so this was a bug fix, not a choice
What it is. When a member's bank asks them to confirm a renewal (3DS/SCA), FMS keeps their access open for a few days while they do it. An owner can also replay Stripe events FMS stored (FIN-9). The code review of 2026-10-03 found that replaying an old "please confirm" event, or Stripe delivering one late, opened a fresh access window even when the invoice had been paid or closed long ago.
Why it matters. A member whose payment problem is unrelated could get free access.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Open the window only while the invoice still waits (built) | FMS checks its own copy of the invoice. Paid, voided or uncollectible means no window. | Correct for replays and for late deliveries alike. | None found. | Done |
| B. Replays skip "please confirm" events entirely | The replay screen refuses that event type. | Simple. | A late live delivery still opens a window. | Small |
Decision: A, taken 2026-10-03 (Thomas), recommended option built. The Business owner and Finance are told on the Decision board. Built 2026-10-03: capture_action_required places the hold only while the stored member_invoices status is action_required. Replays also take only events not yet replayed, so a repeat run moves on. docs/progress/2026-10-03.md.
44. May Claude read a sandbox, for testing? — decided 2026-10-04
Owner: Technical · also: Business owner (for information: a sandbox answer can reach a staff Claude chat, labelled test data) · owner corrected 2026-10-05: the label, the per-environment switch and the read-only login are safeguards Thomas owns; the Business owner's say on the connector itself is #43
What it is. #43 made the Claude connector production only: while the server is switched to a sandbox, the sign-in page says FMS is switched to a sandbox right now and every tool call is refused. Thomas hit that on 2026-10-04 while connecting for the first time, and wants to try the connector against a sandbox as well, without weakening production.
Why it matters. The rule exists for three reasons. (1) Mix-ups: a colleague asking Claude "what did we net last month?" must never get a seeded or cloned sandbox number back as if it were real. (2) The read-only login: in production Claude reads through fms_ai_reader, a Postgres login that can only SELECT the allow-listed tables of the production database. A sandbox is a separate database where that login has no grants, so reading one means either applying the grant script to every sandbox or falling back to the main login with only a session-level read-only default. (3) Side effects: the sign-in email and the audit rows are written on the boot database; the outbox worker follows the active environment.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Keep production only; test on a local FMS with Claude Code (recommended) | No change to the live server. On a laptop (or the rehearsal rig), run FMS with AI_ASSISTANT_ENABLED=true and PUBLIC_BASE_URL=http://localhost:8137 — plain HTTP is allowed for localhost only — switch that instance's own production environment to seeded data, then claude mcp add --transport http fms http://localhost:8137/mcp in Claude Code and sign in. That instance's "production" is test data, so nothing is refused. | Nothing changes on the live server. No test answer can ever reach a colleague's claude.ai. Exercises the real sign-in, tools and audit rows. | Only Claude Code / Claude Desktop can reach localhost; claude.ai in a browser cannot. Needs a laptop with Docker. | None |
| B. Connector follows the active environment | Remove the refusal. Claude reads whatever is switched on; every answer carries an environment: sandbox … line and the tool descriptions say so. The read pool swaps with the environment; sandboxes get ops/ai-reader.sql applied on create/clone so the SELECT-only login works there too. | Staff can demo against a sandbox from claude.ai. | A colleague's Claude silently answers from test data while an owner is mid-rehearsal; the label is the only guard. Grant script per sandbox, pool swap, and the sign-in email path all need work. Widens SEC-17. | 1–2 days |
| C. Per-sandbox owner tick "Claude may read this sandbox" | As B, but off for every sandbox until an owner ticks it on Settings → Sandboxes; the tick clears on every switch back to production, so it never stays on by accident. Every answer labelled. | Deliberate, logged, self-expiring. Demo possible from claude.ai. | Same code as B plus the tick. Still a labelled test answer in a real Claude chat. | 2 days |
| D. Follows the active environment, one owner switch per environment, off everywhere by default (chosen) | Each environment's own database carries a switch (ai_settings, migration 0073), off by default, production included. An owner turns it on for the active environment on Settings → Sandboxes. The sign-in page and every question check the switch of whatever environment is active; a connection made earlier keeps working after a switch only if the new environment is on and the staff account is still active. Every answer names its environment; a sandbox's starts "test data, not real members or money". ops/ai-reader.sql is applied and checked on every environment switch, so the SELECT-only login is the reader in sandboxes too. A clone starts off. | One rule everywhere, deliberate per environment, on the audit trail. Demo or test from claude.ai. Production needs an explicit "on" too, so the connector never answers by accident after a deploy. | A sandbox's test numbers can reach a real Claude chat; the label is the guard. More moving parts: the reader is rebuilt on each switch; the outbox worker drains the boot database's connector mail while a sandbox is active. | 1 day |
Recommendation. A now: it answers "just to test" today with no change and no new exposure. Revisit as C only if colleagues need to demo Claude against a sandbox from claude.ai itself.
Decision: D, taken 2026-10-04 (Thomas): "allow Claude to connect with the current active environment … admin can disable Claude connect in FMS settings in the current env … production is disabled by default." AI_ASSISTANT_ENABLED stays the server-wide master above the switches. After the deploy, an owner turns production on once. docs/progress/2026-10-04.md.
43. Claude reads FMS: who, what, how — decided 2026-10-03
Owner: Business owner · also: Legal / compliance (what leaves FMS, with #22) · taken by Thomas with the colleague who asked for it; the Business owner and Legal / compliance are asked to please confirm (Decision board card 43)
What it is. Staff want to ask Claude questions about FMS in plain words: "Is jane@example.org a member, and when does it end?", "Draft a reply to this member about their renewal date", "What did we net last month in USD?". For that, Claude has to be able to read FMS. The question is who lets it in, and what it may read. Nothing about this lets Claude change anything.
Why it matters. Whatever Claude reads is sent to Claude's servers (Anthropic) to answer the question. It then sits under Anthropic's retention terms for Frontier's Claude plan, outside FMS. A shared login would also make the audit trail (#14) useless: nobody could say who asked what.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. A connector each person signs in to, read-only (chosen) | A colleague adds FMS in Claude (Settings → Connectors) and signs in with their own FMS email and password. Claude then reads through eleven fixed, read-only tools, as that person. Off unless AI_ASSISTANT_ENABLED=true. | Each read is on the audit trail under a name. Switching a colleague off stops Claude at once. Claude sees only what the tools hand it: no notes, settings or Stripe ids. | Data leaves FMS (see below). A colleague could be tricked into approving someone else's Claude (see below). | 2 days |
| B. One shared read-only token | One token, pasted into Claude by whoever sets it up. | Quickest. | Nobody to name on the trail. A leaked token reads until someone notices. Cannot be cut off per person. | 1 day |
| C. No AI access | FMS stays closed to AI tools. Colleagues copy and paste what they need. | Nothing leaves FMS. | Colleagues paste member data into Claude by hand anyway, with no limit and no trail. | None |
Decision: A, taken 2026-10-03 (Thomas, with the colleague who asked). The Business owner and Legal / compliance may overrule; if they say no, set AI_ASSISTANT_ENABLED=false (it is the default) and nothing else needs undoing. What A means:
- Per person, OAuth. Standard sign-in (OAuth 2.1, authorization code with PKCE). Claude registers itself; FMS accepts only Claude's own callback addresses (
claude.ai,claude.com), or a loopback address for Claude Code. The person types their FMS password on an FMS page, never into Claude. Tokens are random, stored only as hashes, last an hour, and renew for at most 30 days. A replayed token ends the whole connection. - Same rules as the console. The sign-in uses the console's throttles and Turnstile, refuses a switched-off account, and refuses a default or temporary password (the person changes it in the console first). The account is checked again on every call, so switching someone off, or an owner setting them a new temporary password, stops Claude on its next question.
- Eleven read-only tools, an allow-list.
about_fms,find_members,member_status,find_teams,team_detail,revenue_report,growth_report,recent_payments,membership_overview,list_plans,donation_totals. Each builds its answer column by column. Claude never reaches/admin/api, so a screen added to the console later shows Claude nothing. - Read-only in the database. Tools read through their own database connection that refuses writes, with a 15-second limit. In production that connection is its own Postgres login,
fms_ai_reader(AI_DATABASE_URL,ops/ai-reader.sql): it can onlySELECTthe tables the tools read, so the database itself refuses any write, and it cannot read staff accounts, tokens, notes, keys, settings, logs or the audit trail. Production will not start with the connector on and no such login, and FMS checks the login at every boot. Nothing dials Stripe or Mailchimp. - Production only. While a sandbox is switched on, Claude is told FMS is not answering. Superseded 2026-10-04 by #44: Claude follows the active environment, only where an owner has switched it on (off by default, production included), and every answer names its environment.
- Never visible to Claude: staff notes, activity rows and IP addresses, Stripe and Mailchimp ids, bank references, invoice links, discount codes, team-invoice addresses, settings, connections, staff accounts, logs, backups, sandbox data. A last check on every answer refuses it whole if it carries a Stripe id, a secret, or a field with a denied name.
- Audited. Every sign-in and every question is a row on Settings → Audit, channel AI assistant, under the person's name. Tool-call rows carry no IP address (the caller is Anthropic's server) and record Claude's own client name instead.
- Cut off. A person disconnects on Your account. An owner uses Revoke all on their Staff page. Switching the account off, or setting a temporary password, ends the connections too. Setting
AI_ASSISTANT_ENABLED=falseand restarting hides every route at once; it does not end the connections (see the 2026-10-04 amendment).
What this leaves open (SEC-17).
- Consent phishing. Someone could send a colleague a sign-in link they started from their own Claude. If the colleague signs in on it, the attacker's Claude reads FMS as that colleague. The sign-in page warns "only continue if you pressed Connect in your own Claude a moment ago". Every connection is listed on Your account and on Staff, and every read is on Audit. FMS emails the person when a new connection appears (built 2026-10-03; since 2026-10-05 the admin alert address gets its own email with the request's IP and user agent; it needs an email relay, otherwise it is skipped), so a sign-in nobody expected is noticed. It does not stop one.
- Data leaves FMS. Member names, emails, plans, dates and payment amounts that Claude reads are handled by Anthropic under the terms of Frontier's Claude plan. Legal / compliance should read this beside #22: a member's request to delete their data cannot reach what Claude has already been shown.
Built 2026-10-03: migration 0071 (boot database only), src/ai/ (oauth, mcp, tools, views, tokens), src/admin/ai_grants.rs, the Your account and Staff cards, tests/ai_assistant.rs; staff guide docs-site/admin/claude-*.md; docs/progress/2026-10-03.md.
Staff guide added, 2026-10-05. Two more tools, search_guide and read_guide_page, answer how-to questions from the staff guide (docs-site/). The guide is compiled into the binary, so the tools read no table and make no network call, and Claude quotes the guide as of the last FMS build. Thirteen tools in all; nothing else in the decision changed. docs/progress/2026-10-05.md.
Project records added, 2026-10-05 (later). Four more tools answer what changed and why (recent_changes), what is planned (read_roadmap), and the two boards (decision_board, risk_board), from the staff guide's project pages. Three choices, all Thomas's (Technical):
- Plain-language records only. The feed carries
CHANGELOG.md,docs/ROADMAP.md,docs/DECISION-BOARD.mdanddocs/RISK-BOARD.md, neverdocs/DECISIONS.mdordocs/RISK-ASSESSMENT.md. Each board names its full record; Claude points there. - By console role, the first role narrowing in this connector. Changelog and roadmap for every role; the decision board for owners and finance (Finance has cards on it); the risk board for owners (the Business owner signs accepted risks). A tool a role may not use is not listed, and a call to it is refused and audited. Members, teams and money still read the same for every role.
- Published by the guide, fetched by FMS, not compiled in. Every guide build writes
ai/records.json(docs-site/.vitepress/records-feed.ts); FMS fetches it hourly with a Cloudflare Access service token (Settings → Connections → Staff guide for Claude, migration0074) and the tools answer from the stored copy. A record edit reaches Claude within the hour of the guide deploying, without an FMS rebuild; a question never waits on the site. Seventeen tools in all.docs/progress/2026-10-05.md.
Project records compiled in, 2026-10-06 (supersedes the third choice above). The business owner expected the records to be part of FMS, like the guide's pages, and chose that over freshness: the four records are compiled into the binary at build time (build.rs, parsed by src/ai/records_md.rs, the guide site's own slug and card-id rules) and the tools read them from memory. No service token, no Connections card, no hourly fetch, nothing to connect; migration 0075 drops guide_feed_settings. The trade, accepted: an edit to a record reaches Claude at the next FMS deploy, not the next guide deploy. Links go to the guide site when STAFF_GUIDE_URL is set, else to the record's file on GitHub. The first two choices (plain-language records only; by role) stand. docs/progress/2026-10-06.md.
Code review, 2026-10-03. Same day, before any deployment: an exact payer filter, one-transaction token exchange, find_members agreeing with member_status, and the database login above (it was a session default before). Nothing in the decision changed. docs/progress/2026-10-03.md G.
Amendment, 2026-10-04 (fourth review, docs/progress/2026-10-04.md). The choice of A stands. Five points changed:
- A connection belongs to the password it was made with. Each connection carries a SHA-256 stamp of the account's stored password hash at sign-in (
ai_grants.password_stamp, migration0072). Every token check compares it with the current hash, so any password change by any path (Your account, an owner's reset,staff-reset, a hand-run UPDATE, a sign-in that raced a change) ends the connection at once. Migration0072ends all existing connections once. - Every sign-in is emailed, not only a new connection. This amends "new connections only" from 2026-10-03. The reason: someone with a password and the app's client id (which is not a secret) could otherwise sign in from their own Claude without anyone hearing of it. A repeat sign-in ("Claude signed in to your FMS account again") also ends everything that app held before. The email says where the sign-in went back to. The grant, the cleanup, the email and the code are written in one transaction, so a sign-in is never silent.
- A restore ends every Claude connection the dump carried and re-applies
ops/ai-reader.sql; FMS also re-applies the script at every boot while the connector is on and the reader login exists. Operators create the login and set its password once, and no longer re-run the script after an upgrade. - The reader login's boot check is an allow-list.
ai::reader::READ_COLUMNSnames every readable column, exactly what the script grants. Any other readable column, role membership, sequence right or create/temporary right stops the boot.READ_TABLES,NEVER_READandNEVER_READ_COLUMNSno longer exist. A tool that reads a new table or column needs the script andREAD_COLUMNSupdated. - An hourly budget. Besides 30 questions a minute, each person has 300 questions at once, then 5 a minute. The first time it runs out FMS emails the admin alert address (at most once an hour per person; if the email cannot be queued, FMS tries again on the next refusal).
- A sign-in and a refresh take turns. They lock the same connection, so a sign-in's clean-out can never miss a token pair that a refresh was adding at that moment.
Also: AI_ASSISTANT_ENABLED=false only hides the routes. It does not end connections, and switching back on resumes live ones. Use Revoke all to end them. Residual risks are in RISK SEC-17.
After deciding. Business owner: confirm, or say "off". Legal / compliance: say whether the Data leaves FMS is acceptable, and whether Frontier's Claude plan (retention, training terms) is the right one for member data. If both confirm, Thomas switches it on in production (ops/RUNBOOK.md §9: first create the read-only database login, then switch it on) and Marketing and Finance can start using it. If Legal says no: leave it off. Keep Option A's code; it does nothing while off.
45. Plan changes from the console — decided 2026-10-10
Owner: Technical · also: Business owner (staff may move a member up a plan, including a free one) and Finance (staff may charge a saved card on confirm; the invoice option; the days to pay) · taken by Thomas with the colleague who asked for it on 2026-10-10; the Business owner and Finance are asked to please confirm (Decision board card 45)
What it is. Until now only the member could change tier, on the website (POST /v1/me/membership/change). The console could grant, renew, stop, re-date or re-method a membership, but not move it to another plan. Staff asked for upgrade and downgrade on the member page, done safely against Stripe when the membership is billed there.
Why it matters. A plan change on a card membership moves money: an upgrade charges the prorated difference, a downgrade turns the unused difference into account credit. Doing it from the console means a staff member decides when a member's card is charged.
What was decided, with the user on 2026-10-10.
| Question | Decision |
|---|---|
| Who may | Finance powers: Finance, Membership manager, Owner (P::Finance on the three routes) |
| Comp / external member moved up | Allowed from the console: a plain repoint with an audit row. The member's own path still refuses it. For an external member, staff record the extra money with Record payment |
| Monthly ↔ yearly on Stripe | Allowed from the console, with the same immediate-invoice amend plus billing_cycle_anchor=now: the cycle restarts today. Charged to the saved card only (see below). The member's own path still refuses it |
| How an upgrade is collected | Both, chosen in the dialog with a plain warning: Charge the saved card now (today's amend; shows the hosted invoice link if it parks) or Send an invoice for the member to pay (no card charge from the console; the change applies when the invoice is paid) |
| Days to pay a sent invoice | Adjustable: a per-environment default on Settings → Team invoices (team_invoice_settings.plan_change_days_until_due, default 14, 1–60), and a per-invoice choice in the dialog (7 / 14 / 30 / other). The due date must fall before the renewal the proration is priced against |
| What the member is told | FMS shows staff the hosted link and does not email it. Stripe emails it only when the account's invoice emails are on. The member's account page shows the waiting change with a pay button, as for their own parked upgrade |
| Comp/external → Stripe-billed by invoice | Phase 2, built the same day: see #46 |
Rules that did not change. Nothing is granted before Stripe says it is paid (a parked or invoiced upgrade writes no plan until invoice.paid / pending_update_applied). One pending change per subscription. Read-only accounts and the live-write brake refuse before any write. A forever discount is dropped by a tier change, as for the member (spec 01); the dialog warns first.
Two departures from the first plan, found while building.
- A cycle switch is never invoiced. Applying
billing_cycle_anchor=nowraises the new period's invoice on the card at once, so an invoice-first switch would bill the new period twice. A cycle switch is therefore Charge the saved card now only; the dialog says why. - The invoice is created before its line. A free-floating pending invoice item (the other order) would be swept onto the member's next card renewal if anything after it failed. FMS creates an empty draft (
pending_invoice_items_behavior=exclude), then the line bound to it by id, finalizes, records the change, and only then asks Stripe to send it.
To confirm in the sandbox live-fire (ops/RUNBOOK.md §6b.2): (a) an amend onto a price with a different interval resets the cycle, with billing_cycle_anchor=now accepted beside payment_behavior=pending_if_incomplete; (b) voiding a parked proration invoice discards Stripe's pending update (FMS lapses its row on the void either way); (c) the invoice-first upgrade end to end.
Hardened after review, 2026-10-10. A sent invoice is voided, never left payable, when the membership is stopped or ends, when the period it priced renews, or a day after it falls due (the health sweep; a failed void is retried next tick). A payment that still lands after that is refused, not granted, and dead-letters for a refund by hand. Stripe is asked to send the invoice through the outbox. A preview never creates a Stripe price. If Stripe will not hold a cycle reset as a pending update, FMS retries once with error_if_incomplete: charged and applied, or nothing changes.
After deciding. Business owner: confirm that staff may move a member up a plan, including moving a free member to a paid tier as a grant. Finance: confirm that staff may charge a saved card on confirm, that the invoice option is wanted, and the default days to pay. If either says no, the change is small: hide the choice in the dialog or lower the role on the three routes. Nobody loses data. docs/progress/2026-10-10.md.
46. Moving a free or bank-paid member to Stripe billing by invoice — decided 2026-10-10
Owner: Technical · also: Business owner (staff may move a member onto paying by invoice) and Finance (the issuing account, FMS emailing the invoice, marking a bank transfer paid) · taken by Thomas on 2026-10-10 as Phase 2 of #45, for both free (comp) and bank-paid (external) members; the Business owner and Finance are asked to please confirm (Decision board card 46)
What it is. A comp or external membership is a grant: nothing renews it and nobody is billed. Staff asked to move such a member onto Stripe without asking them for a card: Stripe sends an invoice each period and the member pays it on Stripe's page or by bank transfer, as an invoiced team does. On the member page, Move to Stripe billing issues one invoice for the first paid period; when Stripe says it is paid, a Stripe subscription billed by invoice (collection_method=send_invoice) replaces the grant.
What was decided, with the user on 2026-10-10.
| Question | Decision |
|---|---|
| Who may | Finance powers (Finance, Membership manager, Owner), as #45 (P::Finance on the three routes) |
| Which members | Comp and external rows on an individual plan that still grant access and are the row that bills the member. Team plans, seats, Stripe rows, backup holds and rows with a change already waiting are refused with a reason |
| Which Stripe account | The issuing account on Settings → Team invoices (team_invoice_settings.stripe_account_id). It must be chosen, match the environment (test key in a sandbox, live in Production) and not be read-only; the live-write brake applies. Invoice checkout itself (enabled) need not be on |
| Target plan | Any active, paid, individual plan, monthly or yearly — never Community, never a team plan. The dialog preselects the member's current plan when it is paid |
| First period | Starts when the grant ends (offered while that is in the future; the default) or today, and runs one plan interval. Starting at the grant end, the invoice must fall due on or before that day; starting today, it must fall due well inside the period it pays for (two days to spare) |
| Days to pay | The per-environment default on Settings → Team invoices (now labelled Days to pay an invoice sent from the member page), 7 / 14 / 30 / other per invoice, 1–60. Renewal invoices get the default too |
| Amount | The plan's mapped price on the issuing account; if none exists, the confirm creates it from the plan's price before the invoice, so a refusal comes before anything is sent. The preview never creates one |
| What the member is told | FMS emails the invoice (branded, the payment link, the days to pay, the bank-transfer instructions and contact email from Team invoices when set). Stripe is not asked to send it: one email, not two. Renewal invoices Stripe raises later are emailed by FMS on invoice.finalized, with a renews soon reminder 14 days before, as for teams |
| Bank transfer | Staff may Mark paid by bank transfer on the waiting change (how, reference, what reached the bank): FMS records it and the outbox asks Stripe to mark the invoice paid_out_of_band; Stripe's invoice.paid then applies the move like any other payment. No Stripe fee exists for it; the admin's net goes to the ledger |
| Access after the move | The grant is retired at once (SubscriptionCanceled, source moved_to_invoice_billing) and the new row grants from the same moment, paid until the period end, so access never dips. Each renewal: 14 days' grace past the paid-until date, then access pauses (the team-invoice sweep) until the invoice is paid |
| Stopping | Cancel on the waiting change, or Stop on the grant, voids the invoice on its own account. Stop on the new Stripe row cancels at Stripe and voids every open invoice FMS knows for it, so nothing can be paid for an ended membership |
| Late payment | More than a day past due: refused, not granted (dead letter, refund by hand), as #45; the health sweep voids such invoices first. A grant whose own end passes while the invoice is open is not a reason to void it |
| The member's own website | A membership billed by invoice cannot be changed to another paid plan from the website (contact us); moving to free Community (their cancel) still works |
Rules that did not change. Nothing is granted before Stripe says it is paid. Every Stripe write goes through the account's registry client (read-only flag, live-write brake) under a key built from the change id. One waiting change per membership. Dunning, the SCA hold and the reroute worker never act on a membership billed by invoice (no card is charged).
To confirm in the sandbox live-fire (ops/RUNBOOK.md §6b.3): (a) the first invoice paid with 4242… creates the subscription send_invoice, anchored at the period end, with no immediate charge (or a $0 invoice only); (b) the bank-transfer path (POST /v1/invoices/{id}/pay paid_out_of_band=true) then invoice.paid; (c) at the anchor (test clock) Stripe raises the renewal invoice, FMS emails it, paying it extends access; (d) Stop voids the open renewal invoice — and whether Stripe voids it itself on cancel.
After deciding. Business owner: confirm that staff may move a free or bank-paid member onto paying by invoice. Finance: confirm the issuing account, that FMS (not Stripe) emails the invoice, and that staff may mark a bank transfer paid. If either says no, the change is small: lower the role on the three routes or hide the button; nothing already moved is affected. docs/progress/2026-10-10.md.
47. Two-step sign-in for every console account — decided 2026-10-11
Owner: Technical · also: every staff member (each sets it up once) · built 2026-10-11 as #10 A option 3, scheduled before cutover on 2026-10-05; for information on Decision board card 47
What it is. A console password alone was enough to read every member and, for the money roles, to move money. A password can be phished, reused from another site or read over a shoulder. Since the console became the place where each colleague signs in as themselves (#37) and Claude signs in with the same account (#43), one stolen password reaches all of it. #10 A scheduled a second step before cutover. RISK SEC-8.
| Option | Pros | Cons |
|---|---|---|
| A. An authenticator app (TOTP, RFC 6238) — six digits that change every 30 seconds, from Google Authenticator, 1Password, Authy or any other | Works offline, free, no third party sees the sign-in; one standard every app speaks; the secret never leaves FMS and the phone | Each person needs a phone app; a lost phone needs a way back in |
| B. A code emailed at each sign-in | Nothing to install | Whoever has the mailbox has the console: a phished Google account opens both. Depends on the relay at every sign-in, and mail is slow or spam-filtered at exactly the wrong moment |
| C. Passkeys (WebAuthn) | Phishing-proof, nothing to type | Browser and device support still uneven across staff machines; recovery and multi-device are harder to explain; a much larger change. Later, alongside A |
Decided: A, with these rules.
| Question | Decision |
|---|---|
| Who | Every role, owners included. No opt-out and no "remember this device" for the code (the known-device cookie still only spares the per-address password throttle) |
| When it applies | At each person's next sign-in. A session open at deploy is asked for the code once (a cookie with no stage reads as password-only). An account not set up yet is held to Your account and the setup screen (/admin/enrol), as a temporary password is, after any temporary password is replaced |
| The code | Six digits, 30-second steps, one step either side for clock drift. A code works once: the last step used is stored and anything at or before it is refused |
| Lost phone | Ten single-use recovery codes, shown once at setup (copy, print, a tick before Done). Each works once in place of the app's code. New ones can be made on Your account (password + a code); they replace all old ones |
| A new phone | Your account → Set up a new phone: the current password and a code (or a recovery code) first. The old phone keeps working until a code from the new one is confirmed; then the recovery codes are replaced and Claude connections end |
| Owner reset | Staff → the person → Reset two-step sign-in (owners, never yourself): clears the secret and recovery codes and ends their Claude connections. They set it up again at their next sign-in |
| Only owner locked out | staff-reset <email> on the server also clears two-step sign-in (as well as setting a temporary password) |
| Guessing | The code step has its own per-account bucket (burst 5, then 1 a minute), taken before the check and refunded on a right code. The console's code step, Your account's proofs and Claude's sign-in page share it |
| Claude's sign-in page (#43) | Asks for the code too (or a recovery code). An account without two-step sign-in is refused there with a pointer to the console |
| Unattended sign-ins (rigs) | staff-totp enrol <email> --secret <base32> sets a known secret so a script can compute codes; it refuses APP_ENV=production without --force-production |
Consequences. Every open console session re-prompts once at deploy, and every colleague sets up an app at their next sign-in (about a minute). The rehearsal rig and the screenshot runner need FMS_ADMIN_TOTP_SECRET (rehearse.sh up enrols the rig admin with it). Sandbox clones carry admin rows as before: admins live in the boot database, so nothing new is copied. The activity trail gets admin.login_totp, admin.login_recovery, admin.totp_start, admin.totp_enrolled, admin.recovery_codes_regenerated and admin.totp_reset; never a secret or a code.
Not changed. Option 2 of #10 A (CSRF checks, revoking other sessions on a password change) is still open: SEC-8 stays Partial. Members' website sign-in is WordPress's and is untouched.
Where it lives. src/totp.rs (codes, QR, recovery codes), src/admin/twostep.rs (the verifier and routes), admin::roles::gate (the hold), migration 0080_staff_two_step.sql, src/ai/oauth.rs (Claude's page), src/bin/staff_totp.rs, frontend/src/components/TwoStep.tsx, frontend/src/pages/Enrol.tsx; tests/admin_totp.rs. docs/progress/2026-10-11.md.
48. Opn Payments (Omise Thailand) as a second provider — decided 2026-10-13
Owner: Technical · also: Business owner (which processor collects first) and Finance (THB settlement, fees, the Omise questions) · built 2026-10-13 as a test integration; the business owner confirms before any live key is connected (Decision board card 48)
What it is. The owner asked to prepare Opn Payments (Omise) Thailand as a possible primary card collector, with Stripe TH and SG second and third. Omise differs from Stripe where it matters: no idempotency key, a decline is HTTP 200 with status: failed, webhooks are signed but retries are not guaranteed, Schedules cannot be updated and say nothing about 3-D Secure, USD can be presented to Visa/Mastercard only, and everything settles in THB. Design: superpowers/specs/2026-10-13-opn-payments-design.md; facts: opn/api-reference.md. RISK FIN-14, FIN-15, SEC-19, OPS-3.
| Option | Pros | Cons |
|---|---|---|
A. Opn alongside Stripe through one account registry — an Opn account is a row in stripe_accounts with provider = 'opn', routed by brand and a priority number | Every invariant, screen and report keeps working; Opn can be tested in a sandbox while Stripe keeps collecting; primary or secondary is one number per account; Stripe stays the fallback for what Omise cannot take (AMEX until enabled, JCB on a USD plan without a THB price) | Two providers to reconcile; FMS must own Opn renewals and their retries; Opn members cannot yet use the Stripe-only paths |
| B. Replace Stripe with Opn | One provider | Moves every existing subscriber (cards do not cross providers, so each re-enters a card); loses SG's AMEX; bets everything on the unanswered 3-D Secure question |
| C. Opn only for THB / PromptPay, later | Smallest change | Does not answer the owner's question (Opn as primary for cards); PromptPay cannot recur |
Decided: A, with these rules.
| Question | Decision |
|---|---|
| Registry (D1) | stripe_accounts keeps its name and gains provider, routing_priority, supported_currencies, charge_limits (migration 0083). For an Opn row: Omise's account_… id, the pkey_ as publishable key, mode from the secret key's prefix, the base64 webhook secret encrypted like a whsec_. OpnRegistry and StripeRegistry each refuse the other provider's rows |
| Billing source (D2) | subscriptions.billing_source = 'opn'. Every billing_source comparison audited: provider-collected → IN ('stripe','opn'); Stripe-API-bound → stays 'stripe' with an explicit refusal (audit) |
| Routing (D4) | route_candidates orders by routing_priority ASC, is_main DESC, region; 100 everywhere is the old order. Opn primary = its priority set to 10. A new Opn account is never is_main. An Opn account that cannot price what the page sells for a brand (no chargeable price, or above its limits) is skipped for that brand: the card form gives it to the next account that takes it, and the Stripe pre-charge assertion skips the same account (WP5, opn::pricing::PlanOffer); if none does, the card is refused. The routed Opn account's no_price refusal stays as the server-side safety net |
| Capability | From Omise's GET /capability (public key): brands → supported_payment_methods, currencies, limits.charge_amount (with the account currency). Structural-ness comes from capability only; every Omise failure_code is transient for routing (invariant 2) |
| Prices | Plans stay USD. An Opn account charges the catalog USD price to Visa/Mastercard only, and only when it lists USD. Any other brand needs an Opn price for that plan on that account (plan_prices, usually THB); the same brand rule binds a USD Opn price. Amounts must sit inside the account's limits |
| Discounts | Internal as ever (invariant 5): once lowers the first charge, forever every charge; a fixed-amount code is refused when the card pays in another currency; a charge that would reach zero is refused; one below the minimum is raised to it |
| Renewals (D3) | FMS-driven, not Omise Schedules. Schedules cannot be updated (delete + recreate), retry on an opaque policy, emit no occurrence events and say nothing about 3-D Secure. A sweep (OPN_RENEWAL_SWEEP_SECONDS, 3600) charges the saved card at the period end as a merchant-initiated charge (transaction_indicator=MIT, recurring_reason=subscription, first_charge) |
| Dunning (D3) | FMS owns it, because Omise offers no dunning. Try 1 at the period end, try 2 at +3 days, try 3 at +7 days; the row stays active meanwhile. Then dunning grace (grace_source='dunning', GRACE_PERIOD_DAYS) + the outreach brief + an admin email; the grace sweep lapses it. No saved card or no price → dunning grace at once. Brand no longer in capability → the reroute worker's Stripe-free Opn branch (structural grace + outreach; there is never a saved card on another provider) |
| No idempotency key | An opn_charge_attempts row is committed before every POST /charges, its id in the charge metadata; a write is never retried; one apply settles every verdict (create answer, return page, webhook, poll) |
| Webhooks and reconcile | POST /v1/webhooks/opn/{account_id}: verify any signature over "{ts}.{body}" with the base64-decoded secret → dedupe → archive → enqueue → 200; the worker re-fetches the object. GET /events poll per account (OPN_EVENT_POLL_SECONDS, 900) plus lost-answer and abandoned-checkout recovery; owner-only POST …/connections/opn/{id}/reconcile |
| 3-D Secure | Full-page redirect to authorize_uri, back at GET /{checkout,donate,gift}/return (signed attempt id), verdict re-read from Omise (invariant 11). Open question 1: whether MIT renewals skip the challenge on a 3-D-Secure-mandatory account. Today a renewal answered pending + authorize_uri becomes an action_required invoice (the account-page Confirm payment banner, sca_hold_until 14 days, the renewal_sca alert); unconfirmed after 14 days it counts as a failed try. No email carries the link (the authorize_uri lifetime is unknown) |
| Settlement | Always THB: gross funding_amount, fee fee + fee_vat, net, rate funding_amount / amount when currencies differ; Reports per settlement currency, never combined. Refunds credit no fee back (UNVERIFIED) |
| Live brake (D5) | ALLOW_LIVE_OPN_WRITES, default off, separate from ALLOW_LIVE_STRIPE_WRITES; reads exempt; webhook provisioning at connect is the carve-out; every boot reports it |
| AI reader (D6) | No Opn ids, no new columns on the allow-list this round |
| What Opn members cannot do yet | Change plan (member or console), seat changes on an Opn team, the billing portal, move to Stripe billing by invoice, and Renew / access-period edits (those are for grants). Each is refused with "This membership is billed by Opn; … is not available for Opn-billed members yet." or its own message. Cancel works and ends the row locally |
Consequences. Two providers to reconcile (OPS-3): Stripe-side Consolidation checks skip Opn rows, duplicate detection includes them, and Reports gain a THB section. Renewals now depend on an FMS loop, so an FMS outage delays Opn renewals (they catch up at the next sweep; the period end does not move). Members moving between providers re-enter their card. Nothing changes for any Stripe, comp or external row.
Before any live key. Ask Omise the five questions in ops/RUNBOOK.md §10.2; run the sandbox live-fire with real Omise test keys; then the business owner decides primary or secondary; then live keys with ALLOW_LIVE_OPN_WRITES=true only for a deliberate window.
Where it lives. src/opn/, services::{connections, opn_charges, opn_events, opn_memberships, opn_renewals, opn_reconcile, opn_test_tools}, api::handlers::opn_webhooks, reroute::worker (handle_opn_payment_failed), migration 0083_opn_provider.sql; eleven tests/opn_*.rs suites. docs/progress/2026-10-13.md.
Addendum 2026-10-14 — Opn support's answers. Opn support answered the runbook's questions in writing (full list: opn/api-reference.md, Answers from Opn support; progress/2026-10-14.md Opn's answers (WP6)). What they settle:
| Question | Answer | What it settles |
|---|---|---|
| 3-D Secure (open question 1) | Dynamic 3-D Secure on our test account: a POST /charges with return_uri is challenged, without it is not. Live needs Opn's risk-team approval. A charge without 3-D Secure leaves a cardholder dispute with the merchant | Renewals are not challenged — they never send return_uri; first charges from the card form send it and are. FIN-15 resolved for test; the dispute liability is FIN-16. A challenged renewal anyway is still held for the member and now also emails staff (Opn changed something) |
| Idempotency | ref_id in the body: the same ref_id returns the existing charge; none is a new request; keep your own protection | ref_id is sent: the attempt row's id. The attempt ledger and the never-retry rule stay. The reconcile re-sends a lost answer only after live-fire opn_ref_id_dedupe passes on Omise |
| Webhook retries | None; use the retrieve API. Sending IPs published | The GET /events poll + reconcile are the only safety net and stay on (OPS-3); the edge allows Omise's IPs for /v1/webhooks/opn/ |
authorize_uri | Valid until visited, once; never finished → pending until it expires | No emailed link for a held renewal (it works once) |
| Secret roll | Old secret 24 h, both signatures sent | What FMS does |
| USD | On for test; THB settlement; fee on the THB amount; ~2–4 % above mid-market | Ledger unchanged; a THB Opn price avoids the FX margin |
| Refund fee | Before settlement = void, fee returned; after settlement or partial = refund, fee kept | opn_refund_totals credits the fee only on a void. A full refund (void or not) excludes the row from Reports as for Stripe, so a fee Omise kept on a full refund after settlement is not in Reports; a partial refund subtracts the gross and keeps the fee. A charge voided before it ever settled records no row (CaptureOutcome::Voided, invoice skip reason voided) instead of retrying |
| Brands | AMEX enabled on test; Visa/Mastercard not mentioned | Re-check the account; Visa/Mastercard on our account still to confirm |
Now the Business owner can decide primary or secondary (Decision board card 48) — the open 3-D Secure question is answered. Before a live key: Opn's risk team approves dynamic 3-D Secure on the live account, and the owner accepts FIN-16 if Opn is to be first for memberships.
49. Where a member manages their billing, and what a cancel does — decided 2026-10-14
Owner: Business owner · also: Technical (built it), Finance (receipts) · decided by the owner 2026-10-14, the cancel rule later the same day; built the same day (WP7a–c); for information on Decision board card 49
What it is. The owner asked that every card-paying member have one place to manage their billing, "via the provider or its own; UI/UX matters, make it intuitive." A Stripe-billed member had Stripe's Customer Portal (the hub's Change your billing details card, an account-scoped session, invariant 6). An Opn-billed member had nothing: Omise has no hosted pages, and FMS refused the portal, plan change and move-to-invoice for Opn rows (#48). Design: superpowers/specs/2026-10-14-member-billing-design.md.
| Option | Pros | Cons |
|---|---|---|
| A. The provider's portal first; FMS's own page only where the provider has none | Card numbers and 3-D Secure stay on the provider's domain, under its compliance; FMS adds the least new card-handling surface; Stripe members see no change | Two looks (Stripe's portal, FMS's page) depending on who collects; what Stripe's portal allows is a per-account setting (#26) |
| B. FMS's own page for both providers | One screen, one look, one domain for every member | FMS takes on a Stripe card-change path (SetupIntent, default card) that Stripe's portal already does better; more PCI surface on FMS's origin. Built first (WP7a), then withdrawn when the owner chose A |
Decided: A (the design's D0), with these rules.
| Question | Decision |
|---|---|
| Stripe-billed member | Manages card, cancel and resume in Stripe's Customer Portal, as before. FMS's /account/billing answers them with a 303 to an account-scoped portal session and never renders. FMS has no Stripe card-change path: POST /v1/me/billing/card{,/start} refuses a Stripe row (card_change_unavailable, Please manage your card in the secure billing portal). Cancel at the period end and Resume also exist in the API for Stripe rows (provider-neutral, tested) |
| Opn-billed member | FMS's own page — /account/billing, /account/billing/card, /account/receipts/{id} in the checkout chrome, behind the member session: a status strip with the one action that matters, the card on file, the plan, cancel, the last payments |
| Change card (Opn) | Omise.js on the member's own Opn account (the checkout's form, SEC-15's card-submit throttle); the card is added to their Omise customer and made default; a card the account cannot charge for the plan is refused before anything is saved. During a failed renewal the new card is charged at once through the renewal attempt ledger. The old card is never deleted |
| Change plan (Opn) | At the period end, no proration, either direction. One booked change per membership; a later choice replaces it; Keep current plan or a cancel drops it; the renewal on that date charges the new plan's Opn price and switches the plan when the charge settles (migration 0084). Staff see it on the member page (From {date}: {plan}); the console still cannot change an Opn plan |
| Cancel and Resume (Opn) | Cancel stops the renewal; access stays until the period end. Resume undoes it before then. Refused for a team plan, a replaced membership, or one already ended |
| What a cancel does (owner, later the same day) | "When a paid subscription is cancelled, the member always becomes a Community member at the end of the billing period, unless they subscribe to another membership." One member cancel, at the period end, for both providers: the website's cancel (POST /v1/me/membership/cancel, [fms_cancel]) and the billing page's call one function (member_billing::cancel_at_period_end) — Stripe cancel_at_period_end=true, never an immediate DELETE; Opn the local flag. Resume until then. This supersedes #15's "at once until cutover" for card memberships |
| Community at the end — the triggers | One function, enroll::after_period_end_cancel, on the same transaction as the row's end, idempotent (advisory lock + the "already a member" test). Stripe: a customer.subscription.deleted event, or any subscription event with status canceled, that FMS applies to a row set to cancel at the period end (the local flag, or the payload's) — set by the member on the website, through the billing page's API or in Stripe's portal. Opn: the renewal sweep ending a row set to cancel at the period end. The "is cancelled" email goes on that day |
| The exception | No Community row while the member holds a live membership (active, trialing, past_due, grace): another paid membership on either provider, a team seat, a comp or bank-paid grant, a gift, or Community itself |
| Not a trigger | A lapse nobody cancelled (Stripe ending a row after its retries, FMS's grace sweep, unpaid / incomplete_expired) — the Opn path never enrolled after a dunning lapse, and Stripe now matches it. Staff Stop, a team owner ending a team (still at once, with the acknowledgement) and a removed seat keep their existing Community enrolment. A row that cannot end at a period end (no Stripe subscription id, in FMS-owned grace, billed by invoice) is still ended at once, with Community |
| Receipts | An Opn payment gets an FMS receipt (amount, date, card brand and last digits, plan, the payment reference, Frontier's details), printable, member-only. A Stripe payment keeps Stripe's hosted invoice. /v1/me/invoices lists both with a receipt_url |
| Provider names | Never in the member's words, except the footer (Payments secured by …) |
Consequences. The view (GET /v1/me/billing) says where billing lives: managed_in = stripe_portal or fms. Plugin 0.48.0 sends the hub's billing card and Update your card to the right place by provider. The Stripe card-change code built under option B was removed (WP7c). Nothing changes for comp, external, team-seat, gift or Community members: the page tells them in one line why there is nothing to manage.
Open (owner).
One cancel or twoand 2.Community after a Stripe period-end cancel— decided the same day (the rows above). 2a. A switch to free Community from the plan list (POST /v1/me/membership/changetocommunity) still ends a paid membership at once. Whether it should wait for the period end too is the owner's call.- Later: an Opn upgrade now with a prorated charge; deleting old Opn cards; paying a Stripe open invoice from FMS after a card change; email receipts for every Opn payment.
- The guide's billing-page screenshots come from a harness, not the rehearsal rig.
Where it lives. src/services/member_billing.rs, src/api/handlers/billing.rs, src/account.rs, services::opn_renewals (the period-end apply and end), services::membership::cancel, services::enroll::after_period_end_cancel, api::handlers::webhooks (the Stripe trigger), migration 0084_member_billing.sql; plugin fms-member-gate 0.48.0 and 0.48.1 ([fms_cancel] + Resume); tests/webhooks.rs, tests/opn_renewals.rs, tests/opn_checkout.rs, tests/member_billing.rs, tests/opn_member_billing.rs, tests/account_pages.rs. docs/progress/2026-10-14.md Member billing, whichever provider collects (WP7).
50. Opn (Omise) prices in THB: hand-typed or derived from the USD catalog price — decided 2026-10-15
Owner: Business owner · also: Finance
What it is. Plans are priced in USD; an Opn (Omise Thailand) account settles in THB. Today a plan reaches Opn for every card brand only if it has a per-account Opn price in THB, typed on Plans → Edit → Opn prices (plan_prices row for the Opn account; opn::pricing::resolve prefers it over the catalog price). Without one, Opn can charge the USD catalog price on Visa/Mastercard only — Omise converts at ~2–4 % over mid-market, the THB amount drifts with the rate — and AMEX/JCB cards are routed on to the next account (Stripe SG).
Why it matters. If Opn becomes primary, every paid plan needs a THB price or most of the benefit is lost; keeping ten hand-typed prices in step with the USD list is a chore that will be forgotten.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Derive, store, refresh on approval | A pricing rate (THB per USD; FMS suggests Omise's Forex rate) and a rounding rule on the Opn account card. FMS fills every plan without an Opn price from catalog USD × rate, rounded, marks the rows derived, shows the list for approval, and charges those stored amounts until Refresh derived prices is pressed and its before/after list approved. A hand-typed price wins over a derived one. Never a live conversion per charge. | No typing; every brand reaches Opn; one steady THB price per member; no FX margin; renewals stable (FIN-1 posture kept: a stored price only changes on approval). | Derived numbers are not "marketing" numbers (฿4,200 not ฿3,990) unless hand-edited. | Small: account setting, a derive/preview/apply action, a derived marker on Plans, tests, guide |
| B. Hand-typed, as now | Staff type each THB price. | Every number chosen. | Chore; a forgotten plan stays USD/Visa-MC-only on Opn. | None |
Recommendation. A.
Decision: Decided 2026-10-15 — C: derived from the provider's rate, cached daily with a 5 % jump guard, allowance switch; supersedes A and B; typed Opn prices removed. The Business owner chose a third option: no stored per-plan THB list at all (98 team rungs are not typeable, and a stored list needs an approval step nobody will keep up). Each Opn account caches one rate (Omise's Forex API, GET /forex/usd, refreshed every FX_RATE_REFRESH_SECONDS; or typed by an owner), and opn::pricing::resolve charges the USD catalog price where the brand may pay it (Visa/Mastercard on an account that lists USD), else round_to_step(catalog × rate × (1 − allowance)) in the account's own currency. A refreshed rate more than FX_RATE_JUMP_BPS (5 %) from the stored one waits for an owner's accept; a provider rate older than FX_RATE_MAX_AGE_HOURS (72) prices nothing. The conversion-fee allowance (0–10 %, off by default) compensates members whose bank converts a THB charge; FMS cannot see a card's issuing country before the price is shown (it is on the card object only after tokenisation), so it applies to every THB charge on the account, and the console refuses to switch it on without a confirmed warning. Renewals are re-rated each period (#51 Q4) and the terms charged are stored on the row. Migration 0086; docs/progress/2026-10-15-fx-pricing.md.
Note 2026-10-15 (DECISIONS #51). The sentence "renewals stable" in option A assumes a member's renewal charges the price they were shown. Today it does not: an Opn renewal re-reads the live Opn price every period (services::opn_renewals::renewal_amount), so an edited price silently changes what existing members pay and a removed price sends them into dunning. #51's work package 1 stores the terms on the row and makes this sentence true for both A and B.
51. What an Omise-paying member is shown and charged — decided 2026-10-15
Owner: Business owner · also: Finance (FX margin, AMEX fees), Technical (built the mechanism) · design: superpowers/specs/2026-10-15-opn-currency-display-design.md (decision card 51)
What it is. Plans are priced in USD and every member page shows USD. Omise can present USD to Visa/Mastercard only; AMEX, JCB and UnionPay must be charged in THB, and Omise settles in THB always. The mechanism built for #48 — a per-account THB Opn price on Plans → Edit, else the USD catalog price on Visa/MC, else the card goes on to Stripe — is correct at the charge, but once a THB price exists it wins for every brand on that account (Visa/MC included, opn::pricing::resolve) while checkout, the team ladder, the discount preview, the gift page and the WordPress account page keep saying dollars. A member is then charged ฿ against a page that said $, and an Opn renewal runs without 3-D Secure, so that page is the evidence a dispute needs (FIN-16). Also found: Opn renewals re-price from the live row every period (nothing is stored on subscriptions), a booked period-end plan change stores no price, and a USD team of about 26 seats or more is offered to Omise and refused after the card is saved (Omise's ฿150,000 ceiling applies after conversion; within_limits cannot check a USD amount). Today, with no THB price typed anywhere, there is no gap: Omise charges dollars to Visa/MC and AMEX/JCB pay dollars through Stripe.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. USD only on Omise | No THB Opn prices (the console refuses one). Omise charges the USD list price to Visa/Mastercard; AMEX/JCB/UnionPay keep going to Stripe (AMEX to Stripe SG) | Honest by construction; nothing to display; no baht list to keep | Omise's 2–4 % FX margin on every charge; AMEX/JCB depend on Stripe for good (drop Stripe SG and AMEX has nowhere to go); needs Visa/MC multi-currency confirmed on the live Omise account; big USD teams need a guard | ~1 day + WP1 |
| B. THB prices, shown honestly | Keep #50's THB prices. The card form shows the price the chosen card type will pay before Pay ("Charged in Thai baht: ฿4,200.00 / year", the USD list price beside it, never "about $"), the team ladder and discount preview re-quote per card group, the form posts the quote and the server charges only what it showed, success pages and the website account page show the real terms | Every brand reaches Omise; no FX margin on baht prices; honest; the shown-and-stored quote is evidence in a dispute | A baht list to keep (#50 A derives it; 98 team rungs); international members' banks convert a THB charge | ~7 days + WP1 (+ #50 A ~2–3 days) |
| C. A full price list per currency | A THB catalog price beside USD everywhere, Stripe prices per currency, a currency choice before the card form | The cleanest model if Frontier ever markets in baht | 12–18 days; doubles FIN-1's mapping rules; answers a marketing question nobody asked | Not now |
| D. B plus a brand split | B, and a per-account "do not route these brands" list so AMEX stays on Stripe SG | Keeps AMEX where fees may be lower (UNVERIFIED) | Stripe SG stays a hard dependency; one more routing knob | B + ~1.5 days |
A live-rate THB price (computed per charge) is rejected: a charged amount must be fixed and shown.
Under every option, now (WP1, ~2.5 days): store price_currency / price_amount on each Opn subscription at creation and at a confirmed change (a booked plan change, a card change that changes the terms), renew at the stored terms, never dunning on a removed price; discount_redemptions.currency; the $-hard-coded formatter; the "use a Visa or Mastercard" copy; a guard for USD team totals above Omise's ceiling. Until B's display ships the console refuses a non-USD Opn price.
Questions (blocking marked).
- Opn primary or secondary for memberships (card 48)? — blocking, chooses A or B.
- On Omise, do Visa/Mastercard pay the baht price or the dollar list price? — blocking for B (dollars on Visa/MC beside baht on JCB/AMEX needs a rule change: an account price would apply only to brands that cannot pay the catalog currency).
- Is it acceptable that a JCB/AMEX/UnionPay member pays in baht at all, rather than dollars via Stripe? — blocking.
- When a baht price changes, do existing members keep their price until they change plan (recommended, Stripe parity) or move with N days' notice? — blocking for WP1's shape.
- THB prices hand-typed or derived — #50; blocking for go-live only if Opn is primary.
- AMEX: Omise or Stripe SG (Finance, fees)? — not blocking.
- Show the USD list price beside the baht price? Recommended yes; "about $Y" no. — not blocking.
- A reminder email with the amount N days before an Omise renewal (FIN-16)? — not blocking.
- To Opn: is Visa/MC multi-currency enabled on the live account, and is JCB refused USD? — blocking for A if Opn is primary (live-fire the JCB test card in USD on Testing → Opn test accounts).
Recommendation. WP1 and the small fixes now; posture A while Opn is secondary; B (with #50 A) if Opn becomes primary. D only if Finance shows AMEX is cheaper on Stripe SG.
Decision: Decided 2026-10-15 — B built. Opn is the first-choice processor (card 48, routing priority 10) with #50 C's derived baht prices, so B's honest display is built (stage 2, docs/progress/2026-10-15-fx-pricing.md). Q7: the USD list price is shown beside the baht price; never an "about $Y" figure (an exchange rate FMS would be inventing). What members see:
- Before Pay, on
/checkout,/giftand/donate: each card group (one per account and price — an Opn account is split into a USD Visa/Mastercard group and a baht JCB/AMEX/UnionPay group) carries the price its card types pay for every plan on the page (cardform::card_groups_for_offer,opn::pricing::PlanOffer::group_prices); a price block directly above Pay shows "$120.00 USD / year" or "฿4,120.00 / year — JCB, American Express and UnionPay cards are charged in Thai baht at today's rate (฿34.32 per $1). List price $120.00. Your bank may convert this to your card's currency.", and the Pay button reads Pay ฿4,120.00. With several card types and none chosen, the heading's USD list price plus "Some card types are charged in Thai baht; you'll see the exact amount when you choose your card type." The team ladder, the gift plan selector and the discount preview (/checkout/discountnow takes the card group's account and brand) re-price per group; a donation in baht says "Your card type is charged in Thai baht: ฿860.00 (for $25.00 at ฿34.32 per $1)". - The binding quote (
services::quote): the form postsquoted_currency/quoted_amount(today's charge — after aoncecode, × seats). Every path recomputes its own amount and charges only when it equals the quote, before any provider call (Stripe) or any card save, row or attempt (Opn); a mismatch or a missing quote charges nothing and re-renders the page with today's price and "The price has changed since this page was opened (now ฿4,120.00). Please check it and press Pay again." The quote is stored on the Opn attempt (opn_charge_attempts.quoted_*, migration0087) as dispute evidence (FIN-16). - After the charge: the success panels say what was charged (and link FMS's receipt for an Opn membership);
/v1/me/membershipserves an Opn row's stored terms; FMS's billing page says a baht price is "re-priced at each renewal at the rate of the day" beside the last terms charged, and the card page shows the price the new card type renews at before Save;GET /v1/planscarries acurrency_notethe WordPress pricing page prints (plugin 0.49.0) while an Opn account charges baht;discount_redemptions.currency(migration0087).
Still open: Q6 (AMEX on Omise or Stripe SG — Finance, fees) and Q8 (a reminder with the amount before an Omise renewal).
Note 2026-10-15 (built with #50 C). Q2 and Q4 are answered and built: on Omise, Visa/Mastercard pay the USD list price when the account lists USD (the catalog price comes first in opn::pricing::resolve; there is no account price any more to override it), and other brands pay a THB price derived from the cached rate; a renewal is re-rated at the current rate each period (as a USD charge moves on a statement) and the terms it charged — subscriptions.price_currency / price_amount / price_rate / price_set_at, migration 0086 — are stored on the row. No usable rate is not dunning: the renewal waits for the next sweep. The within_limits gap is closed (a USD amount is converted at the rate before the THB ceiling is applied). What the member is shown before paying (Q7, WP3) is stage 2.
52. Moving from PMPro segment by segment — decided 2026-10-15
Owner: Business owner · also: Technical (built it), Marketing (what each segment is told, #19) · design: superpowers/specs/2026-10-15-segment-migration-design.md (decision card 52) · RISK MIG-12, MIG-17, MIG-18
What it is. Until 15 October the move from PMPro to FMS had one member-facing switch: the plugin's master switch flipped the whole site at once. Every member was loaded into FMS first, but nobody was served by FMS until everyone was, and the rollback was everyone at once. The Business owner asked to move segment by segment — the teams first, say, then Fanatics, then Followers and Friends, the ~22k Community members last — with new sign-ups going through FMS from an agreed day, each step reversible on its own, and nothing charged or tagged twice.
Why it matters. One flip puts ~23k members on new pages and new emails on one morning, with one rollback for all of them. A fault found on day one then hits everyone, and undoing it undoes everyone. Moving a few hundred paying members first finds the same fault on a few hundred, and only they go back.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. One flip, as before | Import and verify with the switch off, tick it on for everyone, untick to roll back | Built and rehearsed (2026-07-14). One state at a time: the site is PMPro's or FMS's | Everyone at once; one rollback for everyone, lossless only until FMS takes writes (MIG-12); a fault reaches ~23k members on the same morning | None |
| B. A per-member hand-over (built) | Members an importer creates are held: FMS keeps a current copy and acts on nothing for them. Staff hand over a segment, a team or one member on the console's Migration page; FMS then serves them and owns every change. Hand back returns them. New sign-ups are their own segment, a plugin switch | One owner per member at every moment; each step small and reversible on its own; the first segment proves the path on a few hundred members; Hand over everyone is still the one-flip path | Two systems run side by side for weeks: PMPro's cron and Mailchimp add-on still act on members FMS serves (MIG-17); every step needs a fresh import first; a few comms wrinkles | ~2 days, built 2026-10-15 |
| C. By surface | Move one function at a time for everyone: content gating first, then account pages, then checkout, then billing | No per-member state | Every member is served by both systems at once for weeks; the "two membership brains" the 2026-07 design rejected; a rollback is per function, never per member | Larger than B, and each phase touches every member |
Recommendation. B. It is what the owner asked for, and it keeps the earlier rule that one system owns each member: what the 2026-07 design rejected was two systems owning the same member, which B never allows.
Decision: Decided 2026-10-15 — B, on the Business owner's request; built the same day.
- The hold.
users.legacy_held(migration0088) is set on a member the bulk import, the Stripe importer or a mid-migration adoption creates, and on a seat member the import creates; never by FMS's own flows (checkout, New member, a gift claim, a team seat added in FMS, Community sign-up). Re-running an importer never changes a hold, and the delta import never overwrites a member FMS serves. While held, FMS refuses every console and member change to the membership (409legacy_held), sends them no Mailchimp update and no lifecycle email, leaves them out of outreach and dead-letters their structural payment failures; identity (sign-in, address and name changes) keeps working. Access is computed exactly as before. - Segments, derived from the subscriptions cache, one per member: teams (a team always moves whole: admin and every seat), Fanatics, Followers, Friends (highest live paid tier), Community, no membership. The console also hands over one team or one member (not one seat).
- Hand-over clears the hold in one transaction and queues one update-only Mailchimp re-sync per member. Hand back sets it again (members FMS took over only; a member who joined through FMS stays) and queues nothing. Owners and membership managers only.
- The website (Member Gate 0.51.0): with the master switch on and PMPro active, FMS serves only handed-over members; visitors and held members get PMPro exactly as with the switch off. New members join through FMS decides where a visitor joins. PMPro cannot be deactivated while anyone is held or while that switch is off.
- The end state is unchanged: everyone handed over, new sign-ups on, the Mailchimp clean-up, PMPro off. Hand over everyone is the old one-flip path.
The price (the residuals, RISK MIG-17).
- Until PMPro is off, its own expiry cron and Mailchimp add-on still run. A handed-over comp member whose PMPro end date passes loses their PMPro level and their Mailchimp tags; FMS keeps serving them, the plugin lists it, and a re-sync puts the tags back. Keep segment windows short and run the final clean-up.
- A PMPro member who follows a join link after new sign-ups move is sent to log in, then gets PMPro's pages. The member comms (#19) should say so.
- Handing back a member who already changed something through FMS loses nothing in Stripe, but PMPro's copy of a free or bank-paid change is stale (as MIG-12's rollback always was).
- Outstanding PMPro gift codes and discount codes are not migrated by any path (MIG-18, audit #15). Closed 2026-10-27: the Business owner confirmed none exist. If one turns up, staff grant the membership by hand.
Please confirm (Business owner).
- The order and the dates of the segments, and the day new sign-ups move (proposed in
ROADMAP.md; #18 now names the PMPro-off day rather than one flip). - The residuals above, in particular that a handed-over member may receive PMPro's expiry email until PMPro is off, and that segment windows are kept short instead of the gap being closed in code.
The PMPro gift and discount codes (MIG-18): honour unredeemed ones by hand, or have them exported before PMPro is off.Answered 2026-10-27: none exist (owner's statement); MIG-18 closed.
Not changed. The live brakes; the outbox for every side effect; the master switch is still the website's rollback for everyone at once (FMS keeps acting for handed-over members until they are handed back too); the 07-14 runbook still holds the PMPro-deactivation steps.
Where it lives. services::migration, src/admin/migration.rs, src/api/handlers/migration.rs, repository::users (get_or_create_held_by_email, get_or_create_for_adoption, hold_new_import), migration 0088_legacy_hold.sql; frontend/src/pages/Migration.tsx, frontend/src/components/migration.tsx; wordpress-plugin/fms-member-gate/includes/segment.php; tests/migration_segments.rs; runbook migration-runbooks/README.md Phase S; guide docs-site/admin/migration.md. docs/progress/2026-10-15-segment-migration.md.
Note 2026-10-27 — everyone at once; the segments become the rollback. The Business owner reversed the segment schedule: everyone moves to FMS on one morning, with Hand over everyone on the Migration page. The assumption behind the schedule — that some members would migrate while others stayed on PMPro for weeks — was a mistake.
- B stays built, unchanged. No code changes: the hold, the Migration page, Hand over everyone…, Hand back…, the per-team and per-member buttons and the plugin's two switches stay exactly as above. Only the plan changes (runbook
migration-runbooks/README.mdPhase S and The day, step by step;ROADMAP.md). - The forward path is load and verify with everyone held (switch off), then one morning: delta pass, S0 (Stripe writable,
ALLOW_LIVE_STRIPE_WRITES=true), master switch on, Hand over everyone, then New members join through FMS, then the first-hour checks. PMPro stays active for a soak of two to three days, then Phase C. - Segments are the rollback unit. During the soak a segment, a team or a member goes back with Hand back… without the others; the master switch hands the website back for everyone in seconds (FMS keeps acting until members are handed back too). Everyone goes back with the red Hand back everyone… card at the bottom of the Migration page, added the same day (same route, permission and audit row as the per-row hand-back). A straggler the morning's import missed is handed over from their member page.
- MIG-17's window is the soak's days, between the morning and PMPro off, not weeks of segments. The residuals above (PMPro's cron and Mailchimp add-on acting on handed-over members, the stale copy after a hand-back) are unchanged in kind and much shorter.
- Still the Business owner's: the date (#18) and how many days the soak lasts (two to three suggested; the same day is possible). The PMPro gift and discount codes (MIG-18) are answered: the owner confirmed on 2026-10-27 that none are outstanding, so MIG-18 is closed and nothing is settled at PMPro off (one turns up: grant the membership by hand). The order of segments and the day new sign-ups move are no longer questions: new sign-ups move minutes after the hand-over, on the same morning.
docs/progress/2026-10-27-all-at-once-cutover.md.
Note 2026-11-03 — one gate, no soak. The Business owner answered how many days the soak lasts: none. PMPro does not run beside FMS. At any moment exactly one of them decides who can read: PMPro until the switch, FMS after it.
- The morning becomes one window: final export, import and Verify while PMPro is still the gate (FMS switch off, everyone held). Then, back to back: S0, master switch on, Hand over everyone, New members join through FMS, and Phase C (readiness green, member pages swapped, PMPro deactivated, PMPro's Stripe webhooks retired). The plugin's mixed mode (FMS serves handed-over members while PMPro is active) is only the minutes between those steps.
- For: one system answers every member, so the two can never disagree. MIG-17 (PMPro's cron and Mailchimp add-on acting on members FMS serves) has no window. Staff never ask which one is in charge.
- Against: after the switch nothing compares FMS with PMPro. Verify and the people-to-look-at list must be clean or decided before it. The click rollback lasts only that morning (PMPro back on, master switch off, Hand back everyone…). Later, PMPro's copy misses every join, renewal and cancel made in FMS, so going back needs a restore and a reconciliation.
- No code changes. Plan and docs only. The documents that still describe a soak are listed in
docs/progress/2026-11-03-one-gate.md. Until they are revised, this note wins.
Note 2026-11-07 — no segments; the console follows one gate. The Business owner: it is either PMPro or FMS for everyone. There are no segments any more, not even as the rollback unit. The console's Migration tab became the Cutover page: who gates the website (PMPro, FMS, or a hand-over in progress), a Before the morning checklist, The morning's numbered steps with Hand over everyone…, and Go back (the same morning only) with Hand back everyone…. Steps only a person can confirm are ticked on the page and stored in FMS (migration_marks, migration 0100). The per-segment and per-team hand-over and hand-back are removed; a member's own page still hands one straggler over or back. Two blockers to a clean morning were lifted: Consolidation's double-billing fix now runs on a member PMPro still serves (with a warning), and Verify lists every row instead of 50 per box. docs/progress/2026-11-07-one-gate-cutover-page.md.
53. Membership emails decided per payment connection, and the three Opn emails — decided 2026-10-16
Owner: Business owner · also: Marketing (the wording, §2 M5), Technical (built it) · amends §2 M9 · (decision card 53) · RISK MIG-2, FIN-16
What it is. Since §2 (2026-09-29) the four membership emails FMS sends itself — welcome, ends soon, has ended, is cancelled — were switched on once per environment on Settings → Email. The Business owner's point on 16 October: a Stripe account can be told in its own dashboard to send its emails, so members billed on that account need nothing from FMS, while Opn (Omise) sends no member email at all, so an Opn member gets no receipt, no renewal notice and no failed-payment notice (only staff were told, through the outreach brief and an admin email). The switches therefore belong on the payment connection, and an Opn connection needs the three emails Stripe already sends itself.
Why it matters. A member emailed twice about one payment stops reading either sender. A member charged ฿4,110 by a company they never hear from disputes it with their bank (FIN-16). Opn's own receipts page (#49 D5) only helps a member who knows to look.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. One switch per environment, as built | Settings → Email decides for everyone | Nothing to change | Stripe's and FMS's emails double up, or an Opn member gets none | None |
| B. Per connection, independent (built) | A membership is decided in exactly one place: a Stripe/Opn-billed row by the switches on its account's page; free access, bank transfer, gifts and team seats by Settings → Email. Opn connections also get Payment received, Your membership renews soon and We couldn't charge your card | One rule; a Stripe account on its own emails is simply left off; an Opn account gets what Omise lacks | A connection added later starts with everything off, so it must be set up; two screens to look at (the Email page lists every connection read-only) | ~2 days, built 2026-10-16 |
| C. Settings → Email as the master, a connection may only opt out | Every switch on the Email page, plus "this account's provider emails them" per connection | One screen | Cannot give an Opn account a receipt the environment does not send everyone; two switches must agree before a member gets a mail | ~1 day |
Recommendation. B. It is what the owner asked for, and it keeps "one place decides" (the rule #52 set for who serves a member).
Decision: Decided 2026-10-16 — B, on the Business owner's request; built the same day.
- The rule.
services::lifecycle::policy::for_subscriptionis the one derivation: a row withbilling_source IN ('stripe','opn')takes its switches fromstripe_accounts.mail_*on itsstripe_account_id(migration0090); any other row takesemail_sequences, which now means "memberships FMS manages itself". The discriminator isbilling_source, never the account id: free-access, bank-transfer, Community, gift and seat rows all carry the main account's id as a bookkeeping home. Team-invoice and move-to-invoice rows are Stripe-billed on the issuing account, so they follow that account. Ends soon and has ended only ever reach free-access and bank-transfer grants, so they stay on Settings → Email, as does thefms:lapsedtag. - Nothing changes on deploy. The migration copies the environment's welcome and is cancelled switches onto every existing connection. A connection added afterwards starts with everything off, and both screens say so. A sandbox clone switches every connection's emails off again (M9).
- The three Opn emails are account emails (no stop link, M4): Payment received after every successful membership charge (first payment, each renewal, a retry after a card change), with a link to FMS's receipt (#49 D5); Your membership renews soon N days before (1–30, 7 by default, per connection) naming the amount — "about" when it is derived from the dollar price (#50) — and a booked plan change; We couldn't charge your card after each declined renewal try (what happens next: the next try, the last day of access, "this card is no longer accepted"), and after a renewal held for 3-D Secure, which asks the member to confirm on their billing page and never carries the bank's link (#48). A decline on the retry a member triggers by changing their card sends nothing: they are on the page. Every one is queued through the outbox in the charge's own transaction, with a stable key, so a webhook, the poll and the sweep settling the same charge never send it twice.
- Stripe accounts show Welcome and Is cancelled only; switching on receipts, reminders or failed-payment mail there is refused, because Stripe sends those itself (M8) and this round builds no Stripe trigger for them. The columns are provider-neutral, so a later round can.
- Owners set a connection's emails on its page (
POST /admin/api/connections/accounts/{id}/emails, audited); Settings → Email lists every connection read-only beside its own switches.
Please confirm (Business owner). That each Stripe account's dashboard emails stay on (M8) and FMS's Welcome / Is cancelled on those accounts are left as they are today; and the wording of the three Opn emails (Marketing, M5), which Preview shows.
Not changed. The payment-failure policy (Stripe dunning stays Stripe's); the live brakes; the outbox for every side effect; the stop link (ends soon and welcome only); the win-back tag.
Where it lives. services::lifecycle::{policy, billing_mail, templates}, services::opn_renewals, services::opn_memberships, migration 0090_connection_emails.sql; services::connections::set_emails; frontend/src/components/connections/MembershipEmails.tsx, frontend/src/pages/EmailSettings.tsx; tests/connection_emails.rs, tests/opn_billing_mail.rs, tests/lifecycle_mail.rs; guide docs-site/admin/ops/email.md, docs-site/admin/ops/connections.md. docs/progress/2026-10-16.md.
54. The archive reads by newsletter — decided 2026-10-20
Owner: Technical · also: Business owner (confirms the rule — Decision board card 54, which replaces card 39), Marketing (which newsletters each plan includes, on FMS's Plans screen) · supersedes #39 · (decision card 54)
What it is. Under #39 the archive's managers made reader groups — a name and the FMS plans ticked into it — and gave each newsletter in the archive one group. So two lists said who gets a newsletter: FMS's Plans screen (its newsletter ticks decide the emails) and the archive's groups (they decided the back issues). A new plan read nothing until a manager ticked it into a group (card 39), and nothing kept the two lists in step.
Why it matters. A Follower who receives the Daily Briefing by email but finds its back issues locked, or the reverse, is a support ticket and a broken promise. One place to see who gets what is easier to run and to explain, and a new plan should read what it says it includes.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Keep #39's reader groups | Managers make groups of plans in the archive; each newsletter is for one group | Built. Any mix of plans per newsletter | Two lists to keep in step; a new plan reads nothing until a manager acts (card 39) | none |
| B. Read by newsletter (chosen) | A member reads a newsletter's back issues when one of their plans includes that newsletter on the Plans screen. A manager says once per archive series which FMS newsletter it is | One place says who gets what; a new plan reads what its ticks say; nothing to keep in step | A newsletter's back issues can't go to a different set of plans than its emails (only Everyone widens one) | ~1 day |
Decision: B, taken 2026-10-20 (the project owner). Four points settled:
- Reading follows the plan's newsletter ticks (
plans.mailchimp_tags, the literal Mailchimp tag names such asNL - Daily Briefing (Premium)). The Plans screen is the one place to see who gets what. - Opt-outs do not matter. A Follower who unsubscribed from the Daily Briefing emails still reads its archive: FMS reports the plans' granted tags, never
newsletter_preferencesorcrm_state::desired_state_for_address. - A manager picks the newsletter once per series on Manage newsletters, from a dropdown of FMS's newsletter catalog. There is no automatic mapping from Mailchimp sends. The five known series carry a built-in pick (Daily Briefing, Media Monitor, Political Insider, Frontier Backstage, Frontier Fridays → their
NL - …tags); a manager's pick always wins over it. - An Everyone with the archive override per series stays, e.g. to open Frontier Backstage to every member with
archive_accessas a taster.
Unchanged: archive_access (plans.grants_archive, #13) is the front gate and Community never reads; managers read everything; fail-closed — a series nobody has mapped (and that has no built-in pick) is locked for members: headline and lock shown, no excerpt, a 403 article, no search hit; it never opens to all by itself. #39's principle holds: FMS only reports which newsletters a member's plans include; which series is which newsletter, and the rule itself, live in the archive. No archive reading rule is added to FMS.
Built 2026-10-20. FMS: services::member_plans::newsletters (the union of the held plans' ticks, each code read from the row directory names it by; Community adds nothing), newsletters in the hand-off redeem, newsletters per plan on GET /v1/plans/all, and a new public GET /v1/newsletters (active mailchimp_tags by title: tag name, title, category); a helper line under the plan forms' newsletters. The archive: src/rules.ts (replaces src/groups.ts), series_overrides.read_rule (newsletter:<tag> | everyone | locked, NULL = the built-in pick or locked), a one-time conversion on the store's first writable open (a series open to everyone stays so; every other keeps no pick), the session carries newsletters, the Who can read dropdown on Manage newsletters with warnings for series locked for members and for newsletters no plan includes, and locked issues that name the plans which include the newsletter. Spec 10 FR-16; docs/progress/2026-10-20-archive-reads-by-newsletter.md.
After deciding. For the five known newsletters nothing changes for members on the day: each reads by its built-in newsletter, and today every plan that opens the archive (Fanatic and the team plans) includes all five. A series the archive found itself (an editor's send, a new newsletter) that nobody had opened to everyone is locked for members until a manager picks its newsletter on Manage newsletters; the screen lists such series. A new FMS plan reads what its newsletter ticks say, as soon as it grants the archive. Members signed in before the deploy read only Everyone series until they sign in again through the website (a session lasts 12 hours), because their session carries no newsletters yet.
55. A request's environment comes from its credential or its link; production's workers always run — decided 2026-10-24
Owner: Technical · also: Business owner (for information — Decision board card 55) · closes RISK MIG-16's M4 and SEC-20 · (decision card 55)
What it is. One FMS serves production and every sandbox (DECISIONS #44 kept Claude per environment the same way). Until now the console's Switch to decided everything at once: which database the screens showed, which database every website, webhook and member page reached, and which queue the background workers drained. So a tester switching to a sandbox sent production's website, production's Stripe webhooks and production's money queue into the sandbox until somebody switched back (MIG-16's M4, "a sandbox left active parks production's outbox"), and a sandbox cloned from production that pressed Create webhook endpoint deleted production's own Stripe endpoint and took its events (SEC-20). A staging WordPress could not run against a sandbox while the console showed production.
Why it matters. Testing must never touch members or money. The switch was one global value that a tester changes many times a day, and nothing reminded anyone to switch back.
| Option | What it means | Pros | Cons | Effort |
|---|---|---|---|---|
| A. Keep one global switch | As built | Nothing to do | M4 and SEC-20 stay; no staging website | None |
B. A header the caller chooses (X-FMS-Environment) | Every client says which environment it wants | Simple to route | A website, a webhook sender or a member's browser cannot be trusted to say it, or be made to; a forgotten header reaches production | ~2 days |
| C. The member cookie as a hint | A browser that signed in to a sandbox keeps going there | No links to change | One browser, one cookie: a tester's next production visit lands in the sandbox | ~1 day |
| D. One FMS instance per sandbox | Run a second server per test copy | Total isolation | The rehearsal rig already does this; too heavy for everyday testing in the console | — |
| E. Mark sandboxes as "served" | An owner opens a sandbox to outside traffic | Explicit | Not needed once the credential and link decide; another switch to forget | ~1 day |
| F. The credential or link decides (built) | A service key belongs to the environment it was made in; a member or hand-off token carries the environment FMS signed into it; a link names it with ?env=<label>. The console switch only changes what the console shows. Each environment's background work runs as its own set: production's always, a sandbox's while an owner has started it | Production keeps working whatever the console shows; a staging website runs on its own sandbox key; nothing to remember to switch back | Every link FMS makes must carry env= in a sandbox (one helper, links); the website plugin learns its environment (0.55.0) | ~4 days, built 2026-10-24 |
Decision: F, decided and built 2026-10-24 (Technical).
- The rule. A request's environment comes from what it carries, never from the console switch: a service key's home (
service_key_homes, boot database, written before the key), a member or hand-off token'senvclaim (signed), or?env=<label>on a link (productionnames production as booted). First hint wins; two that disagree are refused (409environment_mismatch). Nothing named → the active environment, exactly as before. The console,/mcp,/oauthand/healthstay on the active environment (env_scope). - Production's background work always runs. Each environment with background work has a worker set pinned to its database (
crate::workers): the outbox lanes, member mail, the team-invoice, grace, grant, lifecycle, Opn renewal and reconcile sweeps, exchange rates, the health monitor and the integration probes. Production's starts at boot, before the remembered console choice is restored. A sandbox's runs while an owner has pressed Start on Settings → Sandboxes (environment_workers, kept across restarts); deleting a sandbox stops and forgets it, resetting one restarts it if it was on. Switching never starts or stops a set. - A sandbox never reaches production's people or capacity. Its health monitor records alerts on its own Health page and never mails the admin alert address or posts to
HEALTH_ALERT_WEBHOOK_URL; its member mail goes out only through its own switches (a clone arrives with them off); a sandbox with workers gets its own full-size pool, one without keeps a 5-connection pool released when idle. The live brakes, test-keys-only-in-a-sandbox and the Mailchimp write verdict key on the environment's name, so they hold inside a scoped request or a sandbox set. - Webhook addresses are per environment. A sandbox's Stripe endpoint is
…/v1/webhooks/stripe/{id}?env=<label>; production's is the bare address it always was. Only production replaces the bare one; a sandbox never deletes it. An Omise account has one address, so a sandbox never takes over one that points at production (refused, with the reason); Consolidation names an Opn account whose address points at another environment (webhook_elsewhere).
Behaviour changes (for the record).
- Keys: creating a service key writes its home in the boot database first and fails if that write fails; a sandbox clone carries no service keys (the apps and allow-lists stay) and no registry or worker rows; inside a sandbox a key copied from another environment authenticates nothing; key listings carry
environmentandusable_here, and the Developer page shows Belongs to. Key homes are reconciled at boot, after a sandbox is created and after a backup is restored; a deleted sandbox's homes are kept, so its keys get 401environment_gone. - Requests: member and hand-off tokens carry
env; a token used in another environment is 401wrong_environment;?env=productionreaches production even while a sandbox is active; a sandbox's member cookie isfms_member__<label>; a link naming no environment that exists is 404unknown_environment; an environment being reset is 503environment_resetting. NewGET /v1/environment(service key). - Links: every link, form, redirect and return address a sandbox serves carries
env=<label>(links::local/links::public, mail throughlinks::fill_origin_*); a sandbox's member pages show a Test environment strip; Opn return addresses, gift links, receipt links and member mail name the sandbox they came from; production's are unchanged. - Webhooks: a sandbox creates and checks its own
?env=Stripe endpoint and never deletes production's; Consolidation in a sandbox reportswebhook_missinguntil Create webhook endpoint is pressed there; unknown-account webhooks log at warn with the environment. - Workers and health: production's work runs whatever the console shows (M4 closed); a sandbox's is off until started; Health shows a
workerscomponent, Off for a sandbox whose work is off (not a fault); outbox heartbeats and webhook counts are per environment; retention prunes the boot database and every environment whose work runs. - Website: Member Gate 0.55.0 asks
GET /v1/environment(Test FMS connection, each catalog refresh), shows Environment: … and a sandbox notice, and its links carryenv=. A staging site needs its own sandbox key (one copied from production now belongs to production).
Not changed. The live brakes and the test/live key rule; Claude per environment (#44); the console's switch for what the screens show; the rehearsal rig (its own instance).
Where it lives. src/env_scope.rs, src/env_pools.rs, src/links.rs, src/workers.rs, src/state.rs (Db::scope, pinned_to, health_signals), services::service_keys, services::environments (set_workers), services::connections::webhook_url, migration 0092_environment_binding.sql; frontend/src/pages/{Environments,Developer,SystemHealth}.tsx; wordpress-plugin/fms-member-gate 0.55.0; tests/env_scope.rs. Detail: docs/progress/2026-10-24-environment-binding.md.