MEMBERS-DUES-001F: pure member/account status projection + renewal affordance (source only)#438
Merged
Merged
Conversation
…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).
✅ Deploy Preview for luminous-fox-7c393f ready!
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 authorityrecord, the signed-inuidthe projection is FOR, a caller-supplied reference instantasOfMs, and an owner-configuredrenewalWindowMs, and returns exactly one frozen verdict:{ decision: 'projected', status, entitlement, renewalOffered, activeThroughMs }wherestatus ∈ { active | expiring_soon | upcoming | expired | suspended | ended | pending | none },entitlement ∈ { current_member | not_entitled | decision_pending }taken verbatim from §8.0a,renewalOffered ∈ { true | false }, andactiveThroughMsis the term-end instant to display (ornull);{ decision: 'denied', reason }—malformed_input | malformed_policy(all change nothing).It never throws and imports only
node:utiland./membershipAuthority.Why it composes §8.0a (the one source-only core that imports a sibling)
§8.0a
deriveMembershipEntitlementis the authorization gate: it returnscurrent_member/not_entitled/decision_pending, collapsing every reason (near-expiry, elapsed, refund-suspended, offboarded, not-yet-started, unlinked) into the samenot_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 andnode:util-only, so purity/determinism/no-I/O hold transitively.Safety invariants (all test-locked)
projection.entitlement === deriveMembershipEntitlement(...).entitlement, verbatim, so a status reads as entitled (active/expiring_soon) iff §8.0a returnscurrent_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(neveractive) and an in-windowsuspendedterm →suspended(neveractive).renewalOffered ⟺ status ∈ { expiring_soon, expired }. NEVER for asuspendedterm (clawback — re-purchase could re-grant disputed access or double-charge), anendedterm (owner re-admits), apendingdecision, an already-renewedupcomingterm, a comfortably-activeterm, or a non-member (noneis a join).policy.renewalWindowMs(owner configuration — the sole source; the reducer only comparesendsAtMs - asOfMs <= renewalWindowMs). No hardcoded window (per the MEMBERS-DUES-001 — Build server-authoritative annual membership checkout and renewal #114 owner-decision rule).asOfMs; constructs noDate. Same instant → same projection.input/policyread 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 tomalformed_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 === 0never yieldsexpiring_soon; a window wider than the term is perpetuallyexpiring_soon; the same instant with different windows moves only the active/expiring_soon line); window/term boundaries (asOfMs === startsAtMsin-window;asOfMs === endsAtMs→expired, neverexpiring_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
deriveMembershipEntitlement(MEMBERS-IDENTITY-001A — Define provider-neutral membership authority and entitlement contract #208) is the authorization gate this composes; this adds no authority and only disaggregates the reason §8.0a collapses.projectMemberDiscountsis a consumer of membership status at a content/pricing surface; this is the producer of the display projection such a surface reads, and grants no discount.MEMBERS-DUES-001E(MEMBERS-DUES-001E — Token-refresh/revocation disposition for entitlement reconciliation (source-only) #427) is item 4's token-refresh/revocation disposition — a session-lifecycle decision, not a display read-model.MEMBERS-DUES-001B/C/D) reconcile verified payment / reversal / checkout into term commands; this issues no command and reconciles no payment.membershipClaimReconciliationderives the desired claim value; this writes and reads no claim.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
renewalWindowMsvalue 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.