diff --git a/GITHUB_ISSUES.md b/GITHUB_ISSUES.md index 9bc6e94..6663357 100644 --- a/GITHUB_ISSUES.md +++ b/GITHUB_ISSUES.md @@ -1,5 +1,7 @@ # GitHub Issue Backlog: Secure Commerce Program +**MEMBERS-CANCEL-001A / #705 (2026-10-11):** owner-approved cancellation retains membership access through the already-paid annual term and does not issue an automatic refund. Scope: versioned backend source policy, existing authority expiry/ownership/suspension regressions, and officer handoff. No cancellation endpoint, persistence, claim, Rule, IAM, website, provider or deployment change. #114 and hosted membership/event signup/cancellation remain open. Officer impact: the rule is recorded; no new member control is available. Officer documentation: entrypoint and Events/Shop/Members guide. Deployment evidence: source/local checks only until native issue records otherwise. + **CI-001C4 / #701 (2026-10-11):** exactly `createMemberOnSignUp` and `ensureMemberProfile` were deployed to isolated engineering staging by protected run 38176811598 from `2843388`, with six exact-main checks and normal delegated daliu approval. Both native source archives match the reviewed bundle; Node 22/min0/max2/256 MB/30s and explicit runtime/build identities are verified. Genuine browser App Check/Auth, seven callable denials, signup, recovery, two concurrency cases, profile/claim preservation and background replay passed. Two owned test accounts/profiles were removed; Auth count0/empty collection metadata restored. No schema migration, real data, email, payment, website publication or production deployment. Officer impact: verified staging profiles and no administrator bypass. Officer documentation: low-cost procedure, maps and entrypoints updated. Deployment evidence: native issue and dated guide. #113/#133/#136 and broader core operations stay open. **Prepared:** 2026-07-12 diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1e0e823..ab2fbd0 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -15,6 +15,8 @@ RUNTIME-001 [#691](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/691) pr ## 1. Program outcome +**Membership cancellation policy, 2026-10-11 — #705:** Dave approved retaining access until the already-paid annual term ends, without an automatic refund. Versioned backend source configuration and boundary regressions are a small dependency-clearing slice only. A member-owned durable cancellation request, retries/audit, UI and disposable hosted staging proof remain separate implementation work; do not route this request through legacy admin race cancellation or mark the membership ended early. + MPRC can open a race or merchandise item for sale only when the platform can: - Prove one client request creates at most one active Checkout Session and business record. diff --git a/OFFICER_START_HERE.md b/OFFICER_START_HERE.md index 3cf50c0..513dc5b 100644 --- a/OFFICER_START_HERE.md +++ b/OFFICER_START_HERE.md @@ -16,6 +16,8 @@ Attach the exact wording, public link, or approved photo when you have it. The A ## Choose the closest task +**Membership cancellation decision, 2026-10-11:** Dave approved keeping access through the already-paid annual term, without an automatic refund. [#705](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/705) records the rule and checks the existing term-end logic only. A member cancellation control is **NOT AVAILABLE YET**; do not edit a member record or use race cancellation as a workaround. See [the membership policy handoff](./docs/officers/EVENTS_SHOP_MEMBERS.md#membership-cancellation--policy-approved-not-available-yet). + **Staging profiles verified, 2026-10-11:** [#701](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/701) deployed and tested exactly two profile Functions, using scoped identities, genuine browser App Check and made-up accounts. Signup, recovery, denied requests, retries and concurrency passed; both test accounts/profiles were removed. With Dave's delegated decision, administrator bypass is disabled for production/backend-staging; normal self-approval remains. Original read-only staging is unchanged. Follow [the dated staging proof](./docs/officers/LOW_COST_COMMUNITY.md#engineering-staging-profiles--701). Production, membership, registration and payments are not launched; no officer handles a key or member record. **Backend ownership, 2026-10-11:** Dave approved continuing without a backup owner under [#697](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/697). Dave remains the sole backend release/recovery approver; backup coverage is absent, not complete. This removes only the backup-appointment gate. Protected deployment, staging proof, rollback and all data/payment approvals remain required. See [the sole-owner procedure](./docs/officers/ACCESS_CONTINUITY.md#sole-owner-backend-exception--approved-october-11-2026); no officer handles keys or runs commands. diff --git a/OPERATIONS_RUNBOOK.md b/OPERATIONS_RUNBOOK.md index f3754f3..81abcd6 100644 --- a/OPERATIONS_RUNBOOK.md +++ b/OPERATIONS_RUNBOOK.md @@ -828,6 +828,8 @@ The current protected release plan names only the profile-recovery Functions. It ## 9. Staging dress rehearsal +For future membership cancellation acceptance, use a disposable account and a synthetic approved term. Prove access remains just before the original term end and is denied at/after it; the term and payment evidence must not change. Also prove ownership denials, repeated/lost-response recovery, and that suspension is not undone. #705 currently supplies only the approved policy and existing-authority unit regressions, not the cancellation request or hosted proof. Do not test with production members or real payments. + Before every major payment release: 1. Create a capped test race and a product with one unit in a staging Firebase project. diff --git a/STRIPE_COMMERCE_DESIGN.md b/STRIPE_COMMERCE_DESIGN.md index b8298e8..db35802 100644 --- a/STRIPE_COMMERCE_DESIGN.md +++ b/STRIPE_COMMERCE_DESIGN.md @@ -1219,6 +1219,8 @@ The #161 classifier uses only synthetic fixtures. It can split known legacy mean ### Cancellation +Membership cancellation is separate from race/Checkout cancellation. Under owner-approved #705, voluntary membership cancellation retains access through the already-paid annual term and issues no automatic refund. The versioned source policy is `functions/membershipCancellationPolicy.json`; it does not run a refund, expire a Session, disable an existing refund handler, or establish a deployed cancellation workflow. The first annual dues design remains a one-time payment, not a subscription. Race cancellation rules below are unchanged. + - Cancelling an unpaid record expires the active Stripe Session before or as part of the saga, then releases the hold. - If Session expiry fails transiently, keep a cancellation-pending state and retry; do not merely mark local cancelled while payment remains possible. - Cancelling a paid record requires an explicit refund/no-refund policy and permission. It cannot be the same operation as unpaid cancellation. diff --git a/SYSTEM_DESIGN.md b/SYSTEM_DESIGN.md index 4bf4fd3..0556cd2 100644 --- a/SYSTEM_DESIGN.md +++ b/SYSTEM_DESIGN.md @@ -782,6 +782,8 @@ WEB-002C [#623](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/623) publi ### 8.0a Provider-neutral membership authority and entitlement — SOURCE ONLY, UNUSED +**Approved cancellation rule, 2026-10-11 — #705:** `functions/membershipCancellationPolicy.json` records schema 1 / policy `mprc-membership-cancellation-2026-10-11-v1`. Dave selected access through the already-paid annual term, with no automatic refund. A future voluntary cancellation command must record its request separately, not shorten/extend the approved term, set it `ended` early, change payment evidence, or grant claims. Access still ends at the existing exclusive `endsAtMs`; unlinked, wrong-account, suspended and ended records remain denied. This does not override security suspension, authorize refunds, prevent a later separately approved renewal, or create an automatically recurring subscription. The file is not runtime-imported; regression tests pin the existing authority boundary, not a cancellation command. Persistence, UI and hosted staging acceptance remain **NOT AVAILABLE YET**. + MEMBERS-IDENTITY-001A [#208](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/208), with its command-order correction in MEMBERS-IDENTITY-001H [#451](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/451), defines one unused pure contract that keeps a stable MPRC membership separate from the Firebase account used to sign in. A membership record can exist and receive monotonic term decisions without a UID. Such a record grants no website entitlement. Google, WhatsApp, Strava, email equality, a profile role, and any browser field remain projections or inputs to future reviewed workflows; none is membership authority. ```mermaid diff --git a/docs/officers/EVENTS_SHOP_MEMBERS.md b/docs/officers/EVENTS_SHOP_MEMBERS.md index 032cce9..59ba4d0 100644 --- a/docs/officers/EVENTS_SHOP_MEMBERS.md +++ b/docs/officers/EVENTS_SHOP_MEMBERS.md @@ -10,6 +10,12 @@ The repository contains screens for these jobs. Their presence does not prove th There is currently no proven no-code switch that safely stops all new Stripe payments. Hiding a button, closing an event, or marking a product sold out may leave an already-created Stripe Checkout page payable. +## Membership cancellation — POLICY APPROVED, NOT AVAILABLE YET + +Dave approved this rule on October 11, 2026: cancelling membership keeps access through the already-paid annual term. Cancellation does not issue a refund. It does not extend the term or restore suspended access. Refunds and race cancellation remain separate. + +There is no new member cancellation button or officer procedure from [#705](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/705). Its versioned rule and local tests do not prove a hosted workflow. Do not edit membership dates, mark a term ended, issue a refund, or use an admin race action as a workaround. Ask Dave, the sole release owner, for the separately reviewed request workflow and disposable staging proof before using it. A specialist is still needed to build and release that workflow; an officer needs no terminal, source edit, secret or member record to review this decision. + ## Approval table | Change | Required owners | diff --git a/functions/membershipCancellationPolicy.json b/functions/membershipCancellationPolicy.json new file mode 100644 index 0000000..2224e46 --- /dev/null +++ b/functions/membershipCancellationPolicy.json @@ -0,0 +1,6 @@ +{ + "schemaVersion": 1, + "policyVersion": "mprc-membership-cancellation-2026-10-11-v1", + "accessEndsAt": "paid_term_end", + "automaticRefund": false +} diff --git a/functions/membershipCancellationPolicy.test.js b/functions/membershipCancellationPolicy.test.js new file mode 100644 index 0000000..ba89c9e --- /dev/null +++ b/functions/membershipCancellationPolicy.test.js @@ -0,0 +1,111 @@ +'use strict'; + +const policy = require('./membershipCancellationPolicy.json'); +const { + createMembershipAuthority, + applyMembershipAuthorityCommand, + deriveMembershipEntitlement, +} = require('./membershipAuthority'); + +// These regressions pin the approved policy and the existing authority seam. +// They do NOT execute a cancellation command or prove persistence or deployment. +const START = 1_000_000; +const END = 2_000_000; +const UID = 'uid_cancellation_fixture'; + +function membership(termState = 'approved', linked = true) { + let record = createMembershipAuthority({ + membershipAuthoritySchemaVersion: 1, + membershipId: 'mbr_cancellation_fixture', + commandId: 'cmd_create_fixture', + }); + record = applyMembershipAuthorityCommand(record, { + membershipAuthoritySchemaVersion: 1, + commandType: 'record_term_decision', + commandId: 'cmd_term_fixture', + expectedRevision: 1, + termRevision: 1, + termState, + termId: 'term_cancellation_fixture', + startsAtMs: START, + endsAtMs: END, + planRef: 'plan_fixture', + evidenceRef: 'evidence_fixture', + policyVersion: policy.policyVersion, + }); + if (linked) { + record = applyMembershipAuthorityCommand(record, { + membershipAuthoritySchemaVersion: 1, + commandType: 'associate_account', + commandId: 'cmd_link_fixture', + expectedRevision: 2, + uid: UID, + }); + } + return record; +} + +function entitlement(record, asOfMs, uid = UID) { + return deriveMembershipEntitlement({ + membershipAuthoritySchemaVersion: 1, record, uid, asOfMs, + }).entitlement; +} + +describe('owner-approved membership cancellation policy (source only)', () => { + test('pins a versioned paid-term-end rule with no automatic refund', () => { + expect(policy).toEqual({ + schemaVersion: 1, + policyVersion: 'mprc-membership-cancellation-2026-10-11-v1', + accessEndsAt: 'paid_term_end', + automaticRefund: false, + }); + }); + + test.each([ + [START - 1, 'not_entitled'], + [START, 'current_member'], + [END - 1, 'current_member'], + [END, 'not_entitled'], + [END + 1, 'not_entitled'], + ])('existing paid term at %i returns %s without rewriting it', (instant, expected) => { + const record = membership(); + expect(entitlement(record, instant)).toBe(expected); + expect(record.term.state).toBe('approved'); + expect(record.term.endsAtMs).toBe(END); + expect(record.revision).toBe(3); + }); + + test('repeated evaluation never extends the paid term or changes evidence', () => { + const record = membership(); + const before = JSON.stringify(record); + for (let attempt = 0; attempt < 3; attempt += 1) { + expect(entitlement(record, END - 1)).toBe('current_member'); + expect(entitlement(record, END)).toBe('not_entitled'); + } + expect(JSON.stringify(record)).toBe(before); + }); + + test.each(['suspended', 'ended'])('does not revive a %s term', (state) => { + expect(entitlement(membership(state), END - 1)).toBe('not_entitled'); + }); + + test('does not confer access to a different account', () => { + expect(entitlement(membership(), END - 1, 'uid_other_fixture')).toBe('not_entitled'); + }); + + test('does not confer access to an unlinked membership', () => { + expect(entitlement(membership('approved', false), END - 1)).toBe('not_entitled'); + }); + + test('does not convert a pending decision into a paid membership', () => { + expect(entitlement(membership('decision_pending'), END - 1)).toBe('decision_pending'); + }); + + test.each([START, Number.NaN, Number.MAX_SAFE_INTEGER])( + 'rejects a malformed term end instead of granting access (%s)', (endsAtMs) => { + const record = membership(); + const malformed = { ...record, term: { ...record.term, endsAtMs } }; + expect(() => entitlement(malformed, END - 1)).toThrow(); + }, + ); +});