Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions SYSTEM_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -617,25 +617,26 @@ The server chooses initial timestamps. The current self-edit path sends a Firest

### 8.0a Provider-neutral membership authority and entitlement — SOURCE ONLY, UNUSED

MEMBERS-IDENTITY-001A [#208](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/208) 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 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.
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
flowchart LR
M["Stable opaque MPRC membership ID"] --> L{"Explicit UID association recorded?"}
A["Firebase account"] --> L
M["Stable opaque MPRC membership ID"] --> C["Accept a term decision or one explicit UID association; either may be first"]
A["Firebase account"] --> C
C --> L{"Exact UID association recorded?"}
L -- "No or conflicting" --> N["Not entitled"]
L -- "Yes" --> T{"Versioned term decision complete?"}
L -- "Yes" --> T{"Latest versioned term decision complete?"}
T -- "Missing or decision pending" --> P["Policy decision pending"]
T -- "Suspended, ended, future, or expired" --> N
T -- "Approved and inside explicit start/end" --> E["Fixed current-member result"]
X["Google / WhatsApp / Strava / email / role"] -. "Never grants membership" .-> N
```

Text alternative: a stable membership receives the fixed current-member result only after an explicit UID association and a complete approved term whose explicit half-open time range contains the evaluation time. Missing, conflicting, suspended, ended, future, expired, or undecided state fails closed. External identities, channels, matching email, and roles never grant membership.
Text alternative: a versioned term decision and an explicit UID association may be recorded in either order. An unlinked membership is never entitled, even when it has an approved current term; linking it later preserves the latest term. The membership receives the fixed current-member result only when both the exact UID association and a complete approved term whose explicit half-open time range contains the evaluation time are present. Missing, conflicting, suspended, ended, future, expired, or undecided state fails closed. External identities, channels, matching email, and roles never grant membership.

The CommonJS module creates an account-independent revision-1 snapshot, records one explicit account association, records already-decided term references monotonically, and derives one of three frozen non-identifying results: current member, not entitled, or decision pending. It accepts exact plain objects, bounded server-minted opaque identifiers, safe-integer time values, one current term rather than an unbounded mutable history, and a last-command marker for immediate idempotent retry. An exact last-command retry is read-only. A changed last-command retry, a second/different UID association within the snapshot, a stale record revision, an exhausted safe-integer revision, a skipped or repeated term revision, a reversed time range, an unsupported version/state, an extra field, an accessor, or a proxy fails through one fixed error.
The CommonJS module creates an account-independent revision-1 snapshot, records one explicit account association, records already-decided term references monotonically before or after that association, and derives one of three frozen non-identifying results: current member, not entitled, or decision pending. Multiple monotonic term decisions may be recorded while the membership remains unlinked; a later one-time UID association preserves the latest term rather than replacing or resetting it. It accepts exact plain objects, bounded server-minted opaque identifiers, safe-integer time values, one current term rather than an unbounded mutable history, and a last-command marker for immediate idempotent retry. An exact last-command retry is read-only. A changed last-command retry, a second/different UID association within the snapshot, a stale record revision, an exhausted safe-integer revision, a skipped or repeated term revision, a reversed time range, an unsupported version/state, an extra field, an accessor, or a proxy fails through one fixed error.

This contract does not verify a person, payment, plan, evidence item, refund, dispute, or policy decision. It does not choose calendar-year versus anniversary terms, grace, prices, plan eligibility, retention, or legacy disposition. Its identifier grammar is not a semantic privacy classifier; a future trusted server must mint opaque values and establish every referenced fact. The last-command marker prevents only an immediate changed retry; command IDs are not a durable global replay registry. Durable cross-record UID uniqueness, full command replay history, append-only audit, Firestore schema/Rules, custom claims, token refresh/revocation, runtime authorization, migration, and deployment are later children behind #110, #113, #114, AUTH-003/ADMIN, and the protected release work.
This contract does not verify a person, payment, plan, evidence item, refund, dispute, or policy decision. It does not choose calendar-year versus anniversary terms, grace, prices, plan eligibility, retention, or legacy disposition. Its identifier grammar is not a semantic privacy classifier; a future trusted server must mint opaque values and establish every referenced fact. The last-command marker prevents only an immediate changed retry; command IDs are not a durable global replay registry. #451 changes only the accepted command ordering within schema version 1: existing link-first snapshots remain valid, and because no snapshot is persisted or runtime-adopted, this correction needs no migration or backfill. Durable cross-record UID uniqueness, full command replay history, append-only audit, Firestore schema/Rules, custom claims, token refresh/revocation, runtime authorization, any future migration, and deployment are later children behind #110, #113, #114, AUTH-003/ADMIN, and the protected release work.

The module is imported by no runtime or Functions index. It reads no clock or environment, calls no Firebase/Stripe/provider service, stores nothing, logs nothing, changes no current profile/role/claim, and cannot make #81, annual renewal, discounts, roster export, or officer membership tools available. Source tests and a merge are not Firebase deployment or live behavior proof.

Expand Down
30 changes: 16 additions & 14 deletions docs/officers/EVENTS_SHOP_MEMBERS.md
Original file line number Diff line number Diff line change
Expand Up @@ -892,20 +892,22 @@ Officer steps after every prerequisite has proof:

**Approver:** membership lead plus treasurer and privacy/security owner.

**Prerequisites:** issue [#208](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/208) must be merged for source review. Before any officer or member can use this model, #110 must approve data purposes and retention, a focused #114 child must approve term/payment rules, the identity/admin work must approve who may link or remove a website account, and reviewed Firebase schema, Rules, Functions, deployment, readback, and made-up staged behavior must all have proof. #113 separately owns legacy-source disposition. None of those runtime prerequisites is completed by #208.
**Prerequisites:** issue [#208](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/208) and its command-order correction in [#451](https://github.com/Run-MPRC/Run-MPRC.github.io/issues/451) must be merged at the exact reviewed commits for source review. Before any officer or member can use this model, #110 must approve data purposes and retention, a focused #114 child must approve term/payment rules, the identity/admin work must approve who may link or remove a website account, and reviewed Firebase schema, Rules, Functions, deployment, readback, and made-up staged behavior must all have proof. #113 separately owns legacy-source disposition. Neither source issue completes those runtime prerequisites.

```mermaid
flowchart TD
Record["Stable club membership"] --> Link{"Explicitly linked to this website account?"}
Link -- "No or conflict" --> No["No member access"]
Link -- "Yes" --> Term{"Approved term is current?"}
Record["Stable club membership"] --> Change["Record a term decision or explicit account link; either may happen first"]
Account["Website account"] --> Change
Change --> Link{"Exact account link present?"}
Link -- "No or conflicting" --> No["No member access"]
Link -- "Yes" --> Term{"Latest term decision?"}
Term -- "Decision pending" --> Wait["No access decision yet"]
Term -- "Future, expired, suspended, or ended" --> No
Term -- "Yes" --> Current["Current-member result"]
Term -- "Approved and current" --> Current["Current-member result"]
Outside["Email / Google / WhatsApp / Strava / website role"] -. "Cannot grant membership" .-> No
```

In words: the future system starts with a stable club membership, links it deliberately to one website account, and grants a current-member result only for a complete approved term. Missing, conflicting, undecided, future, expired, suspended, or ended state does not grant access. Email, sign-in method, community channels, and website roles are never proof of membership.
In words: the future system starts with a stable club membership. A versioned term decision and the deliberate link to one website account may be recorded in either order. An unlinked membership grants no access even when its term is approved and current; linking it later preserves the latest term. The current-member result appears only when the exact account link and a complete approved current term are both present. Missing, conflicting, undecided, future, expired, suspended, or ended state does not grant access. Email, sign-in method, community channels, and website roles are never proof of membership.

Officer review steps after the source merge:

Expand All @@ -914,22 +916,22 @@ Officer review steps after the source merge:
3. Do not edit a database record as a workaround.
4. Do not match an account by email as a workaround.
5. Do not create a second account as a workaround.
6. Ask the platform owner to show the fixed #208 report using only made-up, non-identifying reference values.
7. Confirm a membership with no account link returns no website entitlement.
8. Confirm an explicit made-up account link still returns `decision pending` until a complete term decision is supplied.
9. Confirm only an approved term inside its explicit start/end range returns the fixed current-member result.
6. Ask the platform owner to show the exact #208 and #451 reports using only made-up, non-identifying reference values.
7. Confirm one or more monotonic term decisions may be recorded before an account link and that the membership still returns no website entitlement while unlinked.
8. Confirm a later explicit made-up account link preserves the latest term and returns the fixed current-member result only when that term is approved and inside its explicit start/end range.
9. Confirm the original link-first order still returns `decision pending` until a complete term decision is supplied, then reaches the same result from the same final facts.
10. Confirm a different account, missing decision, future or expired range, suspension, ending, out-of-date or conflicting update, or changed immediate retry fails closed without exposing an identifier.
11. Confirm a second attempt to link the same or another website account to one membership fails, even when the update is otherwise current.
12. Confirm the report contains no provider call, database write, claim/role change, migration, log, website route, or production record.
12. Confirm the reports contain no provider call, database write, claim/role change, migration, backfill, log, website route, or production record.
13. End the source review without describing the contract as a working membership system or choosing calendar, grace, price, plan, refund, dispute, or retention policy.

**Expected result:** officers can explain the future authority boundary in plain language. The unused source accepts only the narrow synthetic contract, preserves an account-independent membership, and returns a fixed non-identifying result. Current website accounts, roles, dues forms, discounts, and external channels behave exactly as before.
**Expected result:** officers can explain the future authority boundary in plain language. The unused source accepts both term-first and link-first ordering, preserves the latest term when an account is linked later, grants nothing while unlinked, and returns a fixed non-identifying result only when both prerequisites are satisfied. Current website accounts, roles, dues forms, discounts, and external channels behave exactly as before.

**Stop conditions:** any real member/account/payment/provider data; a request to infer membership from email or role; an unresolved policy choice; a direct Auth, Firestore, claim, or production edit; missing dependency/deployment proof; or a statement that green tests mean member access is live.

**Success proof:** exact #208 issue, pull request, reviewed commit, 46-case focused synthetic report, full repository checks, two independent exact-diff reviews, and a source scan showing the module is not connected to any live Function entry point. Future availability additionally requires separately approved policy, schema, authorization, cross-record account-link uniqueness, durable command replay protection, migration decision, protected Firebase deployment/readback, made-up staging test, website publication, `runmprc.com` verification, and backup-officer walkthrough.
**Success proof:** exact #208 and #451 issues, pull requests, reviewed commits, focused synthetic reports proving both command orders and the no-entitlement-while-unlinked rule, full repository checks, two independent exact-diff reviews, and a source scan showing the module is not connected to any live Function entry point. Future availability additionally requires separately approved policy, schema, authorization, cross-record account-link uniqueness, durable command replay protection, migration decision, protected Firebase deployment/readback, made-up staging test, website publication, `runmprc.com` verification, and backup-officer walkthrough.

**Undo:** before runtime adoption, revert or safely roll forward only the two unused module/test files and these named documentation sections through a reviewed pull request. There is no production record to repair. After any future adoption, use that child's documented rollback; never undo membership by changing a claim or database record by hand.
**Undo:** before runtime adoption, revert or safely roll forward only the reviewed #451 source/test changes and these named documentation sections through a reviewed pull request. There is no production record, migration, or backfill to repair. After any future adoption, use that child's documented rollback; never undo membership by changing a claim or database record by hand.

**Escalation:** membership lead plus treasurer and privacy/security owner. Add the platform owner for source/deployment evidence and use the private incident path if real data or unintended access is involved.

Expand Down
17 changes: 17 additions & 0 deletions functions/membershipAccountStatusProjection.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,14 @@ const unlinkedRecord = makeRecord({
commandType: 'create_membership',
commandId: 'cmd_create',
});
// unlinked + approved term: revision 2, record_term_decision.
const unlinkedApprovedRecord = makeRecord({
association: unlinkedAssoc,
term: decidedTerm('approved', STARTS, ENDS),
revision: 2,
commandType: 'record_term_decision',
commandId: 'cmd_term_unlinked',
});

function input(record, asOfMs, uid = UID) {
return { membershipAccountStatusSchemaVersion: V, record, uid, asOfMs };
Expand Down Expand Up @@ -174,6 +182,14 @@ const ROWS = [
status: 'none', entitlement: 'not_entitled',
renewalOffered: false, activeThroughMs: null,
},
{
name: 'none (approved in-window term but no account association)',
record: unlinkedApprovedRecord,
asOfMs: STARTS + 100 * DAY,
window: WINDOW,
status: 'none', entitlement: 'not_entitled',
renewalOffered: false, activeThroughMs: null,
},
{
name: 'none (linked to a DIFFERENT uid — approved in-window, must NOT be active)',
record: decidedRecord('approved', STARTS, ENDS, otherAssoc),
Expand Down Expand Up @@ -253,6 +269,7 @@ describe('the consistency seam — display never over-states authorization', ()
decidedRecord('approved', STARTS, ENDS, otherAssoc),
pendingRecord,
unlinkedRecord,
unlinkedApprovedRecord,
];
for (const record of records) {
for (let k = -3; k <= 370; k += 7) {
Expand Down
61 changes: 61 additions & 0 deletions functions/membershipAssociation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ const {
EntitlementAction,
classifyMembershipAssociation,
} = mod;
const {
createMembershipAuthority,
applyMembershipAuthorityCommand,
deriveMembershipEntitlement,
} = require('./membershipAuthority');

// ---- fixtures ------------------------------------------------------------

Expand Down Expand Up @@ -176,6 +181,62 @@ describe('happy-path association', () => {
});
});

test('a future trusted caller can translate an authorized verdict after a term-first decision', () => {
const unlinked = createMembershipAuthority({
membershipAuthoritySchemaVersion: 1,
membershipId: 'mem.2026.001',
commandId: 'cmd.create.0001',
});
const approved = applyMembershipAuthorityCommand(unlinked, {
membershipAuthoritySchemaVersion: 1,
commandType: 'record_term_decision',
commandId: 'cmd.term.0001',
expectedRevision: 1,
termRevision: 1,
termState: 'approved',
termId: '2026',
startsAtMs: 1_000_000,
endsAtMs: 2_000_000,
planRef: 'plan.annual.001',
evidenceRef: 'evidence.dues.001',
policyVersion: 'policy.001',
});
const association = command();
const associationState = state({
membership: membership({
membershipId: approved.membershipId,
term: approved.term.termId,
// This is separate trusted evidence. The authority term state does not
// itself verify dues and must never be used to infer this prerequisite.
duesConfirmed: true,
linkState: approved.association.state,
linkedUid: approved.association.uid,
}),
});
const verdict = classifyMembershipAssociation(associationState, association);
expect(verdict.decision).toBe('associate');

// The classifier emits no authority command and persists nothing. This models a
// future trusted caller only after it binds the verdict to the canonical record
// and reads that record's current revision.
expect(verdict.next.membershipId).toBe(approved.membershipId);
const linked = applyMembershipAuthorityCommand(approved, {
membershipAuthoritySchemaVersion: 1,
commandType: 'associate_account',
commandId: association.commandId,
expectedRevision: approved.revision,
uid: verdict.next.linkedUid,
});

expect(linked.term).toEqual(approved.term);
expect(deriveMembershipEntitlement({
membershipAuthoritySchemaVersion: 1,
record: linked,
uid: verdict.next.linkedUid,
asOfMs: 1_000_000,
}).entitlement).toBe('current_member');
});

test('the grant, and its next block, are frozen', () => {
const verdict = classifyMembershipAssociation(state(), command());
expect(Object.isFrozen(verdict)).toBe(true);
Expand Down
Loading