Skip to content

MEMBERS-DUES-001F: pure member/account status projection + renewal affordance (source only)#438

Merged
daliu merged 2 commits into
mainfrom
members-dues-001f-account-status-projection
Jul 21, 2026
Merged

MEMBERS-DUES-001F: pure member/account status projection + renewal affordance (source only)#438
daliu merged 2 commits into
mainfrom
members-dues-001f-account-status-projection

Conversation

@daliu

@daliu daliu commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Closes #437. Child of #114 (MEMBERS-DUES-001 — "Build server-authoritative annual membership checkout and renewal"), tracker item 6. Parent #114 stays open and owner-gated.

What this adds (source only, unused)

One pure, deterministic, non-throwing frozen-verdict reducer for item 6's "minimum member/account status projection and renewal UX" — the member-facing DISPLAY read-model acceptance line 66 requires member pricing and protected content to consume ("the server-derived active-membership projection rather than Auth provider or raw profile fields") — plus its exhaustive test and a SYSTEM_DESIGN §8.0h section. Imported by nothing (no index, route, Rules, callable, or worker) → zero runtime behavior; nothing here awaits the owner-gated deploy.

projectMemberAccountStatus(input, policy) takes the §8.0a authority record, the signed-in uid the projection is FOR, a caller-supplied reference instant asOfMs, and an owner-configured renewalWindowMs, and returns exactly one frozen verdict:

  • { decision: 'projected', status, entitlement, renewalOffered, activeThroughMs } where status ∈ { active | expiring_soon | upcoming | expired | suspended | ended | pending | none }, entitlement ∈ { current_member | not_entitled | decision_pending } taken verbatim from §8.0a, renewalOffered ∈ { true | false }, and activeThroughMs is the term-end instant to display (or null);
  • { decision: 'denied', reason }malformed_input | malformed_policy (all change nothing).

It never throws and imports only node:util and ./membershipAuthority.

Why it composes §8.0a (the one source-only core that imports a sibling)

§8.0a deriveMembershipEntitlement is the authorization gate: it returns current_member / not_entitled / decision_pending, collapsing every reason (near-expiry, elapsed, refund-suspended, offboarded, not-yet-started, unlinked) into the same not_entitled, and applies no renewal-window threshold. A member's account page and the member-pricing surface need those reasons disaggregated into display states plus a renewal affordance; that disaggregation is the ONLY thing this adds. §8.17 / §8.18 / §8.19 each forward-declare it unbuilt ("no member/account status projection or renewal UX (item 6) … remain with #114").

This is a projection OF §8.0a, which it composes rather than re-implements — a deliberate, documented departure from the node:util-only norm. The safety property that matters is display never over-states authorization: composing the real authority makes that TRUE BY CONSTRUCTION and robust to any future change in §8.0a's window logic, and re-implements NONE of §8.0a's record validation (revision math, enum / time / opaque-id checks) — re-implementing it would be the salami failure mode this project forbids. The imported sibling is itself pure and node:util-only, so purity/determinism/no-I/O hold transitively.

Safety invariants (all test-locked)

  1. Display never over-states authorization. projection.entitlement === deriveMembershipEntitlement(...).entitlement, verbatim, so a status reads as entitled (active/expiring_soon) iff §8.0a returns current_member; { upcoming, expired, suspended, ended, none } ⟹ not_entitled; pending ⟺ decision_pending. Proven by composing the REAL §8.0a over every fixture and a swept range of instants — including an approved, in-window term linked to a different uid → none (never active) and an in-window suspended term → suspended (never active).
  2. Renewal offered only where a safe self-serve re-purchase is correct. renewalOffered ⟺ status ∈ { expiring_soon, expired }. NEVER for a suspended term (clawback — re-purchase could re-grant disputed access or double-charge), an ended term (owner re-admits), a pending decision, an already-renewed upcoming term, a comfortably-active term, or a non-member (none is a join).
  3. Invents no policy. The "expiring soon" lead time is policy.renewalWindowMs (owner configuration — the sole source; the reducer only compares endsAtMs - asOfMs <= renewalWindowMs). No hardcoded window (per the MEMBERS-DUES-001 — Build server-authoritative annual membership checkout and renewal #114 owner-decision rule).
  4. Clockless. Consumes asOfMs; constructs no Date. Same instant → same projection.
  5. Confers/mints nothing. The verdict carries a display status, a boolean, the authoritative entitlement, and one term-end instant — no code, token, role, price, amount, account reference, PII, or command. A read-model; access stays gated by §8.0a at the access point.
  6. Hostile-input-safe & non-throwing. input/policy read through a descriptor-based closed-object read (getters never invoked; proxy / revoked proxy / foreign prototype / array / symbol key / extra / missing → malformed); the record is validated by the composed §8.0a, whose throw on any malformed record is caught and mapped to malformed_input; never throws for any value in either argument position.

Tests

functions/membershipAccountStatusProjection.test.js (77 cases): the full 8-status decision matrix with the complete expected verdict; the consistency seam proving invariant 1 against the real §8.0a over every row and a swept fuzz of instants; invariant 2 (renewal ⟺ expiring_soon/expired, incl. a suspended/ended term never offering renewal at any instant); invariant 3 (window boundary inclusive; renewalWindowMs === 0 never yields expiring_soon; a window wider than the term is perpetually expiring_soon; the same instant with different windows moves only the active/expiring_soon line); window/term boundaries (asOfMs === startsAtMs in-window; asOfMs === endsAtMsexpired, never expiring_soon/active); malformed-input and malformed-policy batteries (proxy/revoked/array/foreign-proto/inherited/extra-enumerable-and-non-enumerable/missing/symbol/accessor-never-invoked/bad-version/bad-uid/bad-time, both argument positions) plus a malformed-record battery asserting the projection denies and the composed authority also rejects the same record (the seam is honest); hostile-input never-throws (input/policy/record positions incl. cyclic + trap-throwing proxy); determinism/immutability/frozen-singleton denials with post-call input mutation; a frozen-surface + no-code/token/role/price/PII vocabulary check; and comment-stripped source-boundary checks (require set locked to exactly { node:util, ./membershipAuthority }, membershipAuthority's own require set locked to { node:util } for transitive purity, header §8.0h/MEMBERS-DUES-001F, imported-by-nothing).

Local pre-flight: ESLint 8.56.0 exit 0; jest 77/77 pass (secrets unset).

Distinct from every sibling

Out of scope (stays with gated parent #114)

The "renewal UX" wiring around this read-model, the actual member-pricing and protected-content enforcement that would consume it, the Auth-claim read/write, any token action, renewal Checkout creation, persistence, route, or UI, and the owner-configured renewalWindowMs value itself. This contract performs none of them; it only decides the display disposition and returns it.

Not a deploy proof

Source change, tests, merge, and Firebase deployment are separate states. Merging this deploys nothing and changes no live behavior.

…fordance (source only)

Item 6 of #114. One non-throwing frozen-verdict reducer,
projectMemberAccountStatus(input, policy), that COMPOSES the shipped §8.0a
authority deriveMembershipEntitlement and disaggregates its collapsed
entitlement into eight member-facing display states {active, expiring_soon,
upcoming, expired, suspended, ended, pending, none} plus a renewal affordance.

Crown-jewel invariants (test-locked by composing the REAL §8.0a over every
fixture and a swept range of instants):
  1. Display never over-states authorization: a status reads as entitled
     (active/expiring_soon) iff §8.0a returns current_member (incl. approved
     in-window term for a DIFFERENT uid -> none; in-window suspended -> suspended).
  2. renewalOffered true iff status in {expiring_soon, expired}.

The one source-only core that imports a sibling (a documented departure from the
node:util-only norm): composing §8.0a makes invariant 1 true by construction and
re-implements none of its record validation. Invents no policy (renewalWindowMs
is owner config); reads no clock (consumes asOfMs); mints/confers nothing.
Imported by nothing -> zero runtime behavior. Adds SYSTEM_DESIGN.md §8.0h.

Closes #437. Parent #114 stays open (owner-gated).
@netlify

netlify Bot commented Jul 21, 2026

Copy link
Copy Markdown

Deploy Preview for luminous-fox-7c393f ready!

Name Link
🔨 Latest commit dc0781b
🔍 Latest deploy log https://app.netlify.com/projects/luminous-fox-7c393f/deploys/6a5fc5c7458e6b0008153d0d
😎 Deploy Preview https://deploy-preview-438--luminous-fox-7c393f.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Two descriptive strings in the test file (header comment + one test name)
still read "MEMBERS-DUES-001E" — a different, already-merged slice (§8.0g
token disposition). The assertions already check 001F; align the prose so the
file unambiguously names this slice. Comment/description only; no logic change.
77/77 still green.
@daliu
daliu merged commit 8f69b1c into main Jul 21, 2026
9 checks passed
@daliu
daliu deleted the members-dues-001f-account-status-projection branch July 21, 2026 19:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MEMBERS-DUES-001F — Member/account status projection and renewal affordance (source-only)

1 participant