diff --git a/README.md b/README.md index 644ed24..2c46318 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ Read it on the web here: | `maintenance/librechat-sandbox.md` | Operating the LibreChat evaluation instance (config, overlay image, key sync, pitfalls) | | `maintenance/backups.md` | The two backup systems (agent-host restic + Cloudron app backup): schedules, retention, the cache split and the grouping fix | | `maintenance/woodpecker-ci.md` | The self-hosted CI server and agent: build/pin, manual-only trigger, git-lfs, and its not-yet-green state | +| `maintenance/provisioning-boundary.md` | Which Open Collective contributors auto-provision as members and which are deliberately excluded (org-backed, credit-pack, manual, hidden-email), plus the daily paid-but-unprovisioned watchdog | | `maintenance/2026-11-08-litellm-1.108.0-librechat-1.4.8.md` | The Sunday-evening upgrade-window runbook (gateway → 1.108.0, chat overlay → appstore 1.4.8): candidates, order, exact commands, per-step verification, rollback, window estimate | | `incidents/` | Post-incident write-ups, one file per incident | | `migration/` | The Cloudron server-migration record: the plan, the DNS change sheet, and the captured pre-migration state | diff --git a/maintenance/provisioning-boundary.md b/maintenance/provisioning-boundary.md new file mode 100644 index 0000000..49bfb85 --- /dev/null +++ b/maintenance/provisioning-boundary.md @@ -0,0 +1,75 @@ +# Member provisioning boundary — who auto-provisions from Open Collective + +Which Open Collective contributors become portal members automatically, and +which are deliberately excluded. The portal defines the member set in +`fetch_members()` (member-portal repo, `app/main.py`), acts on it in the +webhook and the nightly `reconcile_memberships()` sweep, and exposes two admin +overrides: `POST /admin/org-link` and `POST /admin/provision`. A daily +watchdog (below) is the net for anything that slips past both. + +## Auto-provisioned + +**Individual OC accounts holding BACKER/ADMIN on the Membership tier (or no +tier — pre-tier contributors are grandfathered) whose email is visible to the +admin token.** The webhook provisions on first payment; the nightly sweep +(03:00 scheduler task) provisions anything missed within 24h and deactivates +lapsed members. `fetch_members()` matches the tier by NAME +(`"membership"`, case-insensitive): renaming the tier on Open Collective +silently empties the member set — the sweep's fail-safe skips reconciliation +rather than deactivating everyone, but provisioning also stops. + +## Deliberately excluded + +1. **Org-backed memberships.** An ORGANIZATION account never exposes an email + (the `... on Individual { email }` fragment matches nothing, and OC returns + org emails as null even to collective admins), so reconcile cannot map one + and does not try. Org-backed memberships become members only when LINKED: + `POST /admin/org-link {"org_slug", "email", "name"}` runs the full + provisioning pipeline and stores `members.org_slug`; the sweep keeps linked + accounts active while the org holds BACKER/ADMIN on the Membership tier and + deactivates them when the org's dues lapse (fail-safe: if the org fetch + itself errors, linked accounts stay active). An unlinked org's first + payment emails info@ with the exact linking command. + +2. **Non-membership tiers (Credit pack).** A one-time purchase grants BACKER + role but is NOT a membership — `fetch_members()` filters it out. This is + why a contributor can pay, appear in the public backer list, and still have + no portal member row: it is a credit-pack purchase, not a lapsed + provisioning. The money is not lost: the webhook's held-credit-pack flow + records a hold, notifies the buyer, and the operator grants, refunds or + transfers it via `/admin/credit-packs-held`; a hold granted to a named + member auto-applies if that person later becomes a member. + +3. **Manual members.** The `MANUAL_MEMBERS` env allowlist (founder/free tier, + the co-op's own accounts) holds members with no OC membership BY DESIGN: + never auto-provisioned from OC, and exempt from lapsed-membership + deactivation. Check for orphaned LiteLLM `member:*` teams after any + `MANUAL_MEMBERS` change. + +4. **Hidden-email / anonymous contributors.** An individual whose email is + null even in the ADMIN view cannot be mapped by reconcile. They are not + silently dropped — the daily watchdog reports them with their public dues, + and provisioning is manual: `POST /admin/provision {"email", "name"}` on + the operator's instruction. Incognito accounts publish an + `incognito-xxxxxxxx` slug, but the ADMIN member query usually still reveals + their email — always check the admin view before treating one as + unmappable (that is what resolved the mmoya case). + +## The watchdog: `oc_unmapped_check.py` + +Daily no-agent cron on the agent host (07:20, job `d0fd1126cc80`), running +`~/.hermes/scripts/oc_unmapped_check.py` — the same silent-until-findings +pattern as the spend-consistency guard. It reads the collective's backer list +through the portal env's admin token and the portal rows read-only via +`cloudron exec`, maps them by email, slug or org_slug, and prints ONLY +member-ish backers (the exact set `fetch_members()` treats as members) with no +portal row at all: slug, display name, whether the admin view sees an email, +dues (amount/frequency/status from the public order list), days as a backer, +and the matching action (org-link vs /admin/provision vs investigate-the-sweep). +Backer nodes with no account object (deleted/incognito-hidden accounts) are +attributed via same-day public orders and stay silent when every same-day +payer is already mapped. Excluded from findings: credit-pack-only backers +(the held-credits flow owns them) and backers matching an inactive portal row +(the sweep re-provisions/reactivates those within 24h). Empty output = clean; +any fetch failure prints FAILED — a check that cannot look never reads as +clean.