Membership Eligibility Pipeline¶
The membership-eligibility pipeline is the organization-level sibling of the event eligibility pipeline. It determines whether a user can join an organization at a target membership tier (optionally on a paid plan) by running a sequence of checks called gates. Each gate evaluates one membership policy and can pass (return None to fall through), block (allowed=False with an actionable next_step), or — unlike the event pipeline — allow early (allowed=True, e.g. for owners or existing members).
The same gate stack powers two things:
- Previews —
GET /join-eligibilityruns the gates with no side effects so the frontend can render the right join CTA. - The application state machine — every read of an application re-runs the gates and may advance its status ("advance on every read").
This is core business logic
Changes to this pipeline affect every user's ability to join organizations, and the "advance on read" state machine mutates OrganizationMembershipRequest rows. The MembershipEligibilityService is the single source of truth for membership-join decisions. Any modifications must be thoroughly tested and reviewed.
Pipeline Overview¶
The pipeline consists of 10 gates executed in strict order. The first gate to return a verdict (blocking or allowing) short-circuits the pipeline; no subsequent gates are evaluated.
flowchart TD
Start([User wants to join org at tier/plan]) --> G1
G1{"1. PrivilegedAccessGate<br/>Owner or Staff?"}
G1 -->|"Yes"| Eligible([Eligible])
G1 -->|"No"| G2
G2{"2. OrgVisibilityGate<br/>Org visible via for_user()?"}
G2 -->|"Yes"| G3
G2 -->|"No"| Rejected1([Rejected: org_not_visible])
G3{"3. BlacklistGate<br/>Blacklisted or fuzzy match?"}
G3 -->|"Clear"| G4
G3 -->|"Hard blacklisted"| Rejected2([Rejected: blacklisted])
G3 -->|"Fuzzy match"| Rejected2b([Rejected: requires_verification])
G4{"4. AlreadyMemberGate<br/>Existing membership / subscription?"}
G4 -->|"ACTIVE at target tier"| Allowed1([Allowed: already_member])
G4 -->|"PAUSED at target tier"| Rejected3([Rejected: membership_paused])
G4 -->|"Free apply + live subscription"| Rejected3b([Rejected: duplicate_active_subscription])
G4 -->|"No conflict"| G5
G5{"5. AcceptRequestsGate<br/>Org accepts membership requests?"}
G5 -->|"Yes"| G6
G5 -->|"No"| Rejected4([Rejected: not_accepting_requests])
G6{"6. TierAvailabilityGate<br/>Tier belongs to org? Plan active & on tier?"}
G6 -->|"Yes"| G7
G6 -->|"No"| Rejected5([Rejected: tier/plan_unavailable])
G6 -->|"Free apply + tier has an active plan"| Rejected5b([Rejected: tier_requires_subscription])
G7{"7. ApplicationStatusGate<br/>Latest application REJECTED?"}
G7 -->|"No"| G8
G7 -->|"Yes"| Rejected6([Rejected: application_rejected → reapply])
G8{"8. MembershipQuestionnaireGate<br/>Resolved questionnaire passed?"}
G8 -->|"Passed / None required / Member exempt"| G9
G8 -->|"Missing / Pending / Failed"| Rejected7([Rejected: questionnaire status])
G9{"9. ManualApprovalGate<br/>Approval policy satisfied?"}
G9 -->|"Not required / APPROVED / no application yet<br/>(annotates requires_approval)"| G10
G9 -->|"PENDING application — awaiting staff"| Rejected8([Rejected: requires_approval])
G10{"10. PaymentReadyGate<br/>Plan-bearing application?"}
G10 -->|"No plan"| Eligible
G10 -->|"Plan payable, approval satisfied"| Allowed2([Allowed: proceed_to_payment])
G10 -->|"Approval required, nothing on file"| Rejected9([Rejected: requires_approval → submit_application])
G10 -->|"Offline / not on sale / at cap /<br/>online but org not Stripe-ready / duplicate subscription"| Rejected10([Rejected: plan_not_online · plan_unavailable ·<br/>org_not_stripe_connected · duplicate_active_subscription])
style Eligible fill:#2e7d32,color:#fff
style Allowed1 fill:#2e7d32,color:#fff
style Rejected1 fill:#c62828,color:#fff
style Rejected2 fill:#c62828,color:#fff
style Rejected2b fill:#c62828,color:#fff
style Rejected3 fill:#c62828,color:#fff
style Rejected3b fill:#c62828,color:#fff
style Rejected4 fill:#c62828,color:#fff
style Rejected5 fill:#c62828,color:#fff
style Rejected5b fill:#c62828,color:#fff
style Rejected6 fill:#c62828,color:#fff
style Rejected7 fill:#c62828,color:#fff
style Rejected8 fill:#c62828,color:#fff
style Rejected9 fill:#c62828,color:#fff Gate Details¶
1. PrivilegedAccessGate¶
The first gate provides a fast path for organization owners and staff: they are immediately granted access with allowed=True; no further gates are checked.
Source: events/service/membership_manager/gates.py: PrivilegedAccessGate
2. OrgVisibilityGate¶
The organization must be visible to the user via Organization.objects.for_user() (visibility rules, bans, hard blacklists). If not, blocks with reason ORG_NOT_VISIBLE and no next_step.
Controllers convert this to a 404
The gate exists as a defense-in-depth layer; the /join-eligibility and /apply controllers additionally raise a plain 404 for invisible organizations before running the pipeline, so slug-guessing callers cannot enumerate private orgs (see Endpoints).
Source: events/service/membership_manager/gates.py: OrgVisibilityGate
3. BlacklistGate¶
Mirrors the event pipeline's blacklist semantics:
- Hard block: definitively blacklisted (FK or hard identifier match). Blocks with reason
BLACKLISTED, nonext_step, no recourse. - Soft block (fuzzy name match): routes through the whitelist verification flow:
- Already whitelisted → gate passes.
- Whitelist request pending → blocks with
next_step=WAIT_FOR_WHITELIST_APPROVAL(recoverable — the application row staysPENDING). - Whitelist request rejected → blocks with no
next_step. - No request yet → blocks with reason
REQUIRES_VERIFICATIONandnext_step=REQUIRES_INVITATION; the frontend routes the user through the existing whitelist-request endpoint.
Blacklist/whitelist state is computed lazily on first access and cached for the service instance.
Source: events/service/membership_manager/gates.py: BlacklistGate
4. AlreadyMemberGate¶
Inspects the user's existing memberships and subscriptions in the organization:
- Free-apply bypass guard: when applying via the free path (
plan is None) while holding any non-terminalMembershipSubscriptionin the org, blocks withDUPLICATE_ACTIVE_SUBSCRIPTION— a user must not clobber their paid membership through the free flow. - ACTIVE membership at the target tier → allows early with
next_step=ALREADY_MEMBER(the only allowing verdict that carries anext_step). - PAUSED membership at the target tier → blocks with
MEMBERSHIP_PAUSEDand nonext_step. PAUSED is admin/Stripe-imposed; the user must contact the organization to resume. - ACTIVE membership at a different tier falls through, so subsequent gates can re-gate tier upgrades.
Source: events/service/membership_manager/gates.py: AlreadyMemberGate
5. AcceptRequestsGate¶
The organization must have accept_membership_requests=True. Otherwise blocks with NOT_ACCEPTING_REQUESTS and next_step=REQUIRES_INVITATION (the user can still join via an organization invitation token).
Source: events/service/membership_manager/gates.py: AcceptRequestsGate
6. TierAvailabilityGate¶
Sanity-checks the target tier and (optional) plan:
- The tier must belong to this organization → otherwise
TIER_UNAVAILABLE. - Free apply (
plan is None) against a monetized tier — one that carries at least one active subscription plan — blocks withTIER_REQUIRES_SUBSCRIPTION. A free/applymust not hand out a paid tier's benefits without payment; the user is directed to subscribe to a plan instead. - When a plan is supplied: it must be
is_activeand belong to the target tier → otherwisePLAN_UNAVAILABLE. Plan-bearing applications fall through toPaymentReadyGate.
Source: events/service/membership_manager/gates.py: TierAvailabilityGate
7. ApplicationStatusGate¶
Blocks when the user's most recent application for this (org, tier) is REJECTED, with reason APPLICATION_REJECTED and next_step=REAPPLY.
REJECTED is terminal for the row, not for the user: a fresh POST /apply creates a new PENDING row (the partial unique constraint only forbids duplicate PENDING rows), and since this gate only inspects the latest application per tier, the new PENDING row supersedes the old REJECTED one and the gates evaluate the fresh attempt from scratch.
Permanently blocking a user
Organizations that want to permanently keep someone out must blacklist them — rejecting an application only rejects that attempt.
Source: events/service/membership_manager/gates.py: ApplicationStatusGate
8. MembershipQuestionnaireGate¶
Enforces the resolved membership questionnaire: the tier-level membership_questionnaire override wins; otherwise the organization's default_membership_questionnaire applies; NULL on both means no questionnaire is required (see Policy resolution).
Evaluation against the user's most recent READY submission:
members_exempt=Truegrandfathers existing members: any ACTIVE membership at any tier in the org skips the questionnaire. Orgs that want to re-gate tier upgrades setmembers_exempt=False.- No submission → blocks with
MEMBERSHIP_QUESTIONNAIRE_MISSINGandnext_step=SUBMIT_QUESTIONNAIRE(+questionnaire_id). requires_evaluation=False→ a submission's mere existence passes the gate.- Evaluation missing or
PENDING_REVIEW→ blocks withMEMBERSHIP_QUESTIONNAIRE_PENDINGandnext_step=WAIT_FOR_QUESTIONNAIRE_EVALUATION. APPROVED→ passes, unless the approval is older thanmax_submission_age(then treated as missing → resubmit).- Rejected → retake policy branches:
can_retake_afterset and cooldown still running →MEMBERSHIP_QUESTIONNAIRE_RETAKE_COOLDOWNwithnext_step=WAIT_TO_RETAKE_QUESTIONNAIREandretry_on.- Cooldown elapsed → treated as missing (resubmit).
- No retake configured (
can_retake_afterisNULL) → terminal failureMEMBERSHIP_QUESTIONNAIRE_FAILED, nonext_step. Note this is the opposite of the admission path, where aNULLcooldown means "retake immediately" — see Questionnaires → Retry mechanics. - Attempts cap reached → terminal failure
MEMBERSHIP_QUESTIONNAIRE_ATTEMPTS_EXHAUSTED, nonext_step: whatever the retake policy says, the user can never satisfy the gate again.
Those last two — MEMBERSHIP_QUESTIONNAIRE_FAILED and MEMBERSHIP_QUESTIONNAIRE_ATTEMPTS_EXHAUSTED — are the two reason codes in TERMINAL_REJECTION_CODES (see State machine).
Source: events/service/membership_manager/gates.py: MembershipQuestionnaireGate; the retake/staleness rules themselves in questionnaires/utils/retake_policy.py (shared with the submit endpoint).
9. ManualApprovalGate¶
When the resolved manual-approval policy is on (tier-level requires_membership_approval override when not NULL, else org default_requires_membership_approval):
APPROVEDapplication → falls through (the staff decision is in).PENDINGapplication → blocks withREQUIRES_APPROVAL,next_step=WAIT_FOR_APPROVAL, and theapplication_idto poll.- No application on file (or only a terminal
CANCELLED/COMPLETEDone) → falls through so the user can actually apply. Blocking here soft-locked users who had never applied: the frontend rendered a disabled "application pending" state with no path to the Join CTA (#787). Instead the gate setshandler.approval_required_annotation, socheck_eligibility()'s final allowing verdict carriesreason_code=REQUIRES_APPROVALwith no prose and nonext_step— the frontend can still say "joining goes through staff approval" while offering the Join CTA.
REJECTED never reaches this gate — ApplicationStatusGate blocks it first with next_step=REAPPLY.
The fall-through is deliberate, not an _allow
A returned verdict short-circuits the chain, and PaymentReadyGate runs after this gate — so a plan-bearing check with no application must still fall through to reach gate #10's requires_approval → submit_application block (driven by the approval_required_annotation this fall-through sets).
Source: events/service/membership_manager/gates.py: ManualApprovalGate
10. PaymentReadyGate¶
The final pre-payment readiness check. A no-op when no plan is supplied. A plan-bearing check that survives every prior gate ends here in an allowing PROCEED_TO_PAYMENT verdict — POST /subscribe acts on it, opening Stripe Checkout for an ONLINE plan or activating a FREE one outright (null checkout_url).
FREE plans (#832) are self-serve like ONLINE ones but involve no Stripe object at all, so they carve out of the first two blocks below: neither plan_not_online nor org_not_stripe_connected applies to them. Everything else — sales status, cap, duplicate subscription, the approval annotation — is enforced identically. They keep the proceed_to_payment next step: the frontend calls the same POST /subscribe, which answers with a null checkout_url and an already-ACTIVE subscription.
Blocks, in order:
| Condition | Reason code | next_step |
|---|---|---|
Approval required but no application on file (ManualApprovalGate's fall-through annotation) | requires_approval | submit_application — create the application first so staff have something to approve |
| Plan is OFFLINE (staff-managed — no member-facing way to pay) | plan_not_online | — |
| Plan is ONLINE and org not Stripe-connected | org_not_stripe_connected | — |
Plan sales_status ≠ OPEN, or at its max_subscriptions cap | plan_unavailable | — |
| Caller already holds a non-terminal subscription in the org | duplicate_active_subscription | — (/subscribe deliberately lets this one fall through to the subscription machinery, which resumes a pending checkout with a 409 instead of dead-ending) |
Source: events/service/membership_manager/gates.py: PaymentReadyGate
Response Structure¶
Every check returns a MembershipEligibility object (a Pydantic BaseModel), serialized on the wire as MembershipEligibilitySchema:
class MembershipEligibility(BaseModel):
allowed: bool
organization_id: uuid.UUID
tier_id: uuid.UUID | None = None
plan_id: uuid.UUID | None = None
reason: str | None = None
reason_code: ReasonCode | None = None # stable machine-readable identifier
next_step: MembershipNextStep | None = None
questionnaire_id: uuid.UUID | None = None
application_id: uuid.UUID | None = None
retry_on: datetime.datetime | None = None
| Field | Type | Description |
|---|---|---|
allowed | bool | Whether the user can join (or already has joined) |
organization_id | UUID | The organization being checked |
tier_id | UUID \| None | Target tier, when one was supplied |
plan_id | UUID \| None | Target plan, when one was supplied |
reason | str \| None | Translated human-readable reason — for display only |
reason_code | ReasonCode \| None | Stable machine-readable identifier mirroring reason (snake_case, never translated) — switch on this in clients |
next_step | MembershipNextStep \| None | Suggested action to make progress |
questionnaire_id | UUID \| None | The questionnaire to submit / being evaluated |
application_id | UUID \| None | The application row to poll (injected by /apply and GET /applications/{id}) |
retry_on | datetime \| None | When a failed questionnaire can be retaken |
Note
Unlike the event pipeline, allowed=True can carry a next_step (ALREADY_MEMBER from AlreadyMemberGate) so the frontend can render "You're already a member" instead of a join CTA.
MembershipNextStep Enum Values¶
All possible MembershipNextStep values and when they are returned:
| NextStep | Gate | When Suggested |
|---|---|---|
submit_questionnaire | MembershipQuestionnaireGate | Questionnaire not submitted, expired, or retake cooldown elapsed |
wait_for_questionnaire_evaluation | MembershipQuestionnaireGate | Submission awaiting evaluation |
wait_to_retake_questionnaire | MembershipQuestionnaireGate | Failed questionnaire, retake cooldown running (retry_on set) |
wait_for_approval | ManualApprovalGate | Manual-approval policy on, staff decision pending |
wait_for_whitelist_approval | BlacklistGate | Fuzzy-matched, whitelist request pending |
requires_invitation | BlacklistGate, AcceptRequestsGate | Verification needed, or org not accepting requests — join via invitation/whitelist flow |
submit_application | PaymentReadyGate | Plan-bearing check on an approval-gated tier with no application on file — POST /apply first |
proceed_to_payment | PaymentReadyGate | Plan-bearing check passed every gate (allowing verdict) — POST /subscribe opens Checkout (ONLINE) or activates immediately with a null checkout_url (FREE) |
already_member | AlreadyMemberGate | ACTIVE membership at the target tier (allowing verdict) |
reapply | ApplicationStatusGate | Latest application was rejected; a fresh POST /apply starts over |
Reason Codes¶
As in the event pipeline, reasons are emitted in two forms: reason (translated prose, display only) and reason_code (stable snake_case ReasonCode — switch on this). The advance_application state machine itself switches on reason_code.
ReasonCode value | English message |
|---|---|
org_not_visible | "This organization is not available." |
blacklisted | "You are not allowed to join this organization." |
requires_verification | "Additional verification required." |
whitelist_pending | "Your verification request is pending approval." |
whitelist_rejected | "Your verification request was rejected." |
already_active_member | "You are already a member at this tier." |
not_accepting_requests | "This organization is not accepting new members." |
tier_unavailable | "The requested tier is not available." |
tier_requires_subscription | "This tier requires a paid subscription. Subscribe to a plan to join." |
plan_unavailable | "The requested plan is not available." |
application_rejected | "Your application was rejected." |
membership_questionnaire_missing | "Membership questionnaire has not been filled." |
membership_questionnaire_pending | "Waiting for questionnaire evaluation." |
membership_questionnaire_failed | "Membership questionnaire evaluation was insufficient." |
membership_questionnaire_retake_cooldown | "Membership questionnaire evaluation was insufficient. You can try again later." |
membership_questionnaire_attempts_exhausted | "You have reached the maximum number of attempts." |
requires_approval | "Your application is awaiting staff approval." |
plan_not_online | "This plan is not configured for online checkout." |
org_not_stripe_connected | "This organization cannot accept online payments yet." |
duplicate_active_subscription | "You already have an active subscription in this organization." |
membership_paused | "Your membership at this tier is paused. Contact the organization to resume." |
Application State Machine¶
Applications are OrganizationMembershipRequest rows with five statuses:
| Status | Meaning | Terminal? |
|---|---|---|
PENDING | Gates still blocking on a recoverable condition, or awaiting staff | No |
APPROVED | A plan-bearing application passed the gates (or was staff-approved); awaiting the member's payment via /subscribe. Swept to CANCELLED after 30 unpaid days (events.expire_stale_approved_applications) | No |
REJECTED | Terminal questionnaire failure observed on read, or staff rejected | Yes |
CANCELLED | User cancelled their own application | Yes |
COMPLETED | Membership materialized (free path) | Yes |
A partial unique index (unique_pending_application_per_user_org_tier, with nulls_distinct=False) enforces at most one PENDING application per (organization, user, tier) — including the tier-less (NULL) legacy case. Terminal rows are excluded, which is precisely what makes re-applying after rejection possible.
stateDiagram-v2
[*] --> PENDING: POST /apply (idempotent get_or_create)
PENDING --> PENDING: read — gates still blocking<br/>(recoverable reason)
PENDING --> COMPLETED: read — gates pass, free path<br/>(tier set, no plan) → member materialized
PENDING --> APPROVED: read — gates pass, plan-bearing<br/>/ staff approves plan app
PENDING --> COMPLETED: staff approves free app<br/>(member materialized)
PENDING --> REJECTED: terminal reason_code on read<br/>or staff rejects
PENDING --> CANCELLED: user cancels
APPROVED --> CANCELLED: user cancels
APPROVED --> COMPLETED: /subscribe → Stripe activation settles
COMPLETED --> [*]
REJECTED --> [*]
CANCELLED --> [*] Advance on every read¶
advance_application() (events/service/membership_manager/service.py) is called by POST /apply and GET /applications/{id}. On each call it:
- Re-fetches the row under
select_for_update(of=("self",))so concurrent reads serialize. The full state prefetch runs inside the lock (a handful of short queries) — prefetching outside and re-checking inside would open a TOCTOU window.of=("self",)locks only the OMR row; the nullable FK joins would otherwise trip Postgres' "FOR UPDATE cannot be applied to the nullable side of an outer join". - Runs the full gate chain against current state.
- Terminal rows never advance —
REJECTED/CANCELLED/COMPLETEDreturn a fresh eligibility verdict for the response payload but perform no mutation. PENDING+allowed=Trueadvances:- tier set, no plan →
_complete_free_application():OrganizationMembercreated/updated toACTIVEat the tier, row →COMPLETED. - plan set → row →
APPROVED(awaiting payment via/subscribe; the Stripe activation settles itCOMPLETED). - tier-less and plan-less → stays
PENDING(legacy path; staff assigns a tier at approval).
- tier set, no plan →
PENDING+ blocked auto-rejects only whenreason_codeis in the explicit terminal allowlist:
TERMINAL_REJECTION_CODES: t.Final[frozenset[ReasonCode]] = frozenset(
{
ReasonCode.MEMBERSHIP_QUESTIONNAIRE_FAILED,
# A user at the attempts cap can never satisfy the questionnaire gate again,
# whatever the retake policy says — as terminal as an outright failure.
ReasonCode.MEMBERSHIP_QUESTIONNAIRE_ATTEMPTS_EXHAUSTED,
}
)
A missing next_step is NOT a terminal signal
WHITELIST_PENDING, ORG_NOT_VISIBLE, MEMBERSHIP_PAUSED, DUPLICATE_ACTIVE_SUBSCRIPTION, etc. can all block with no user-facing next step, yet every one of them is recoverable upstream (staff whitelists the user, the org becomes visible again, ...). Auto-rejection therefore keys strictly off the TERMINAL_REJECTION_CODES allowlist — everything else leaves the row PENDING so it can resume when the condition clears. An auto-rejection also queues the same MEMBERSHIP_REQUEST_REJECTED notification a staff rejection would, so system-driven rejections are never silent.
Free-path defense in depth¶
_complete_free_application() refuses to materialize a member when the user has any non-terminal MembershipSubscription in the org, or when the existing membership at the target tier is PAUSED. AlreadyMemberGate is the primary line of defense; these guards catch any future gate reordering that would bypass it.
Staff approval¶
approve_membership_request() (events/service/organization_service/membership.py) uses the application's pre-set tier, or requires the staff caller to pass one (tier-less legacy applications):
- No plan → row →
COMPLETED,OrganizationMembercreated/updatedACTIVEat the tier,MEMBERSHIP_REQUEST_APPROVEDnotification on commit. - Plan-bearing → row →
APPROVED; the member then callsPOST /subscribe, which re-runs the gates, opens Stripe Checkout, and links the created subscription to the application (application.subscription). No membership is granted yet —_ensure_active_membermints it on the first Stripe activation and settles the applicationCOMPLETEDin the same funnel.
Endpoints¶
All member-facing routes live on MeMembershipApplicationsController (events/controllers/me_applications.py); staff decisions on OrganizationAdminMembershipRequestsController (events/controllers/organization_admin/membership_requests.py, manage_members permission).
| Method & Path | Purpose |
|---|---|
GET /api/me/organizations/{slug}/join-eligibility?tier_id=&plan_id= | Pure preview — runs the gates with no side effects; drives the join CTA |
POST /api/me/organizations/{slug}/apply | Create or refresh an application; returns 201 with {application, eligibility} |
GET /api/me/applications/{id} | Poll one application — advances state on read |
GET /api/me/applications | Paginated list of the caller's applications across orgs |
POST /api/me/applications/{id}/cancel | Cancel own PENDING/APPROVED application; idempotent on terminal rows |
GET /api/organization-admin/{slug}/membership-requests | Staff: list applications (filterable by status) |
POST /api/organization-admin/{slug}/membership-requests/{id}/approve | Staff: approve (optional tier_id for tier-less applications) |
POST /api/organization-admin/{slug}/membership-requests/{id}/reject | Staff: reject → REJECTED + notification |
Anti-enumeration: invisible orgs are 404s¶
Both GET /join-eligibility and POST /apply resolve the organization through Organization.objects.for_user() and raise a plain 404 when it is not visible to the caller. A 200 carrying org_not_visible (or a 403) would confirm the org's existence — and leak its UUID — to slug-guessing callers.
/apply pre-gate hard blocks¶
POST /apply runs a preview of the gate chain before creating any row, and refuses with a 403 when the verdict is blocking with next_step in {None, REQUIRES_INVITATION}. This avoids:
- persisting
PENDINGrows for users with no in-app recourse (hard blacklist, org not accepting requests, tier/plan unavailable, terminal questionnaire failure), - queueing noisy staff notifications for dead-end applications.
Recoverable blocks (questionnaire pending, manual approval, whitelist pending) do create the row — it is exactly the polling record that drives advance-on-read. A REJECTED-latest-row verdict carries next_step=REAPPLY and likewise passes the pre-gate: the fresh PENDING row supersedes the rejected one.
Additional /apply behavior:
plan_id(optional) makes it a paid application: the tier is derived from the plan whentier_idis omitted, the row records the target plan, and a re-POSTwith a different plan repoints thePENDINGrow.- Idempotent: a re-
POSTwhile aPENDINGrow exists re-runs the gates against the existing row (and may advance it); a concurrent duplicate insert caught by the partial unique index fetches the winner instead of erroring. questionnaire_submission_id(optional) is persisted onto the row as audit evidence. It must reference aREADYsubmission owned by the caller for the questionnaire that actually gates this (org, tier) — a nonexistent id and another user's id fail identically (anti-enumeration). A re-POSTcan attach a submission to aPENDINGrow that lacked one.
Polling contract¶
The frontend flow after POST /apply:
sequenceDiagram
actor User
participant API as Controller
participant MM as membership_manager
participant DB as Database
User->>API: POST /me/organizations/{slug}/apply
API->>API: 404 if org invisible; 403 on hard-block preview
API->>MM: apply_for_membership(...)
MM->>DB: get_or_create PENDING (partial unique index)
MM->>DB: SELECT ... FOR UPDATE (advance_application)
MM->>MM: run 10 gates
MM->>DB: maybe advance PENDING → APPROVED/COMPLETED/REJECTED
MM-->>API: (application, eligibility)
API-->>User: 201 {application, eligibility(application_id)}
loop until terminal or allowed
User->>API: GET /me/applications/{id}
API->>MM: advance_application(app)
MM-->>API: (application, eligibility)
API-->>User: 200 {application, eligibility}
end eligibility.next_step tells the user what to do (submit the questionnaire, wait for staff, ...); application.status tells them where the row stands. Both are returned together on every poll.
Policy Resolution: Tier Overrides, Org Defaults¶
Two membership policies can be set at the organization level and overridden per tier (events/service/membership_manager/resolvers.py):
| Policy | Org field | Tier override | Resolution |
|---|---|---|---|
| Membership questionnaire | Organization.default_membership_questionnaire | MembershipTier.membership_questionnaire | Tier FK wins when set; NULL on both → no questionnaire |
| Manual approval | Organization.default_requires_membership_approval | MembershipTier.requires_membership_approval (nullable boolean) | Tier value wins when not NULL; NULL → inherit org default |
Model-level clean() validation guarantees the questionnaire FK belongs to the same organization and is of type MEMBERSHIP.
Tier gates and subscription plans coexist (Phase 2)¶
POST /subscribe runs the full gate stack before opening Stripe Checkout, so a tier can be gated (questionnaire and/or manual approval) and monetized, and both are enforced. The Phase-1 mutual-exclusion guards (_assert_tier_ungated / _assert_tier_not_monetized) are gone; enabling a gate on a monetized tier and putting a plan on a gated tier are both ordinary mutations now.
Deliberate boundaries of the unified pipeline:
change-planandreviveare not re-gated. Both act on someone who already passed the gates once (an existing member switching plans, a lapsed member reviving within the revival window). Re-running questionnaire/approval on them would let a config change retroactively lock out standing members; an organizer who wants a lapsed member re-vetted can reject the revival path by archiving the plan.- Org-wide defaults now apply to paid tiers.
default_requires_membership_approval/default_membership_questionnairefold into the resolved policy exactly as on free tiers. Deploy note: any pre-existing org that combines an org default with active plans becomes gated on its paid tiers at deploy — audit with:
SELECT o.slug FROM events_organization o
WHERE (o.default_membership_questionnaire_id IS NOT NULL OR o.default_requires_membership_approval)
AND EXISTS (SELECT 1 FROM events_membershipsubscriptionplan p
JOIN events_membershiptier t ON p.tier_id = t.id
WHERE t.organization_id = o.id AND p.is_active);
TIER_REQUIRES_SUBSCRIPTIONstill blocks the free path on a monetized tier (gate #6): the verdict means "join by subscribing"; the public tier listing (GET /organizations/{slug}/membership-tiers, #830) gives the frontend the plans to offer.- Approved-but-unpaid applications expire. The daily beat
events.expire_stale_approved_applicationscancels plan-bearingAPPROVEDrows older than 30 days with no live linked subscription, so the staff board doesn't accumulate applicants who never paid.
Architecture¶
MembershipEligibilityService¶
The MembershipEligibilityService class (events/service/membership_manager/service.py) is the orchestrator, mirroring the event pipeline's EligibilityService:
- Prefetches state in
__init__: owner/staff ids, all memberships keyed by tier, the existence of any non-terminal subscription, the latest application per tier (DISTINCT ON (tier_id)ordered by-created_at, so a long history of terminal rows never bloats the query), and the resolved questionnaire plus the user's latestREADYsubmission. - Blacklist state is lazy: computed on first property access via a
@cached_property, so previews that short-circuit earlier (owner, invisible org) never pay for fuzzy matching. - Instantiates the 10 gates in order and runs them sequentially in
check_eligibility(); the first non-Noneverdict wins.
def check_eligibility(self) -> MembershipEligibility:
for gate in self._gates:
result = gate.check()
if result is not None:
return result
return MembershipEligibility(allowed=True, organization_id=..., tier_id=..., plan_id=...)
Relationship to the event pipeline¶
| Event pipeline | Membership pipeline | |
|---|---|---|
| Question | "Can this user attend this event?" | "Can this user join this org at this tier/plan?" |
| Gates | 11 | 10 |
| Allowing short-circuits | Owner/staff only | Owner/staff, and ALREADY_MEMBER |
| Waivers | Invitation waiver flags | None — invitations bypass the pipeline entirely (org tokens grant membership directly) |
| Side effects | None (transactional ops in EventManager) | advance_application mutates the application row on read |
| Result type | EventUserEligibility | MembershipEligibility |
The event pipeline's MembershipGate (gate 7 there) is a consumer of this pipeline's output: next_step=BECOME_MEMBER on an event sends the user into this membership flow.
Cross-references¶
- Membership Subscriptions — plans, ONLINE/OFFLINE billing, dunning, revival; the paid-membership machinery this pipeline hands off to at
PROCEED_TO_PAYMENT. - Eligibility Pipeline — the event-level sibling this document mirrors.
- Questionnaires — evaluation modes behind
MembershipQuestionnaireGate. - Permissions & Roles — the
manage_memberspermission gating staff decisions.