You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
#494 shipped the access cutoff as a tenant-wide hard block announced by a
single email at scheduling time, with no in-app signal for admins and no
written statement of how wide the block reaches. This closes all three gaps.
Notification ladder. `decideAccessCutoffAction()` returns 'schedule' exactly
once per cutoff, so the school got exactly one message, 14 days out, and
nothing at T-7, T-1 or on the day access actually stopped. A new
`access_cutoff_notifications` ledger — unique on (tenant_id, cutoff_at, stage)
— lets the existing daily sweep send whichever rung is due without repeating
itself. A row is written only when at least one admin address actually
received the mail, so an undelivered rung is retried by the next sweep;
`dueCutoffNotificationStage()` returns at most one rung (the most urgent
reached-but-unsent one) so a stale warning can never land beside "access is
now paused". The cron response now reports per-stage sends and failures.
Delivery is judged on sendEmail's boolean rather than on whether it threw:
lib/email/send.ts swallows both a missing Mailgun config and an API error and
returns false, which made #494's try/catch around the send near-decorative.
Admin banner. <AccessCutoffBanner /> renders in the dashboard shell for
admins on every admin page, not just billing, in a scheduled state (days
remaining) and an active state (access paused). Not dismissible: a dismissed
countdown to losing all student access is indistinguishable from no
countdown. The student half of this gap was already closed by #509
(/dashboard/student/access-suspended), verified as a regression check.
Blast radius. docs/MONETIZATION.md now states explicitly that the cutoff is
tenant-wide and all-or-nothing (a free-plan school's 51st student costs all
51 their access), what it does not touch (enrollments, entitlements,
purchases, staff and author access, preview lessons), and why per-student
narrowing was considered and rejected.
Also fixes a locale bug found during verification: both the new banner and
the #509 student page formatted dates with toLocaleDateString(undefined),
which resolves to the Node process locale on the server, so /es pages
rendered English dates.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168MCSb3b3iWM5TSb25Rp3v
Copy file name to clipboardExpand all lines: docs/MONETIZATION.md
+33Lines changed: 33 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -246,6 +246,39 @@ The two sections above are **creation-time soft caps only** — they block a *ne
246
246
-**Page/route gates**: `requireCourseAccess()` (`lib/services/course-access-guard.ts`) on every student course route — course detail, lessons, exercises (list + detail), exams (list, taker, result, review), community — plus `hasCourseAccess()` in the certificate, AI tutor and exercise-submission APIs. A refusal caused by the tenant cutoff (rather than by a missing entitlement) sends the student to `/dashboard/student/access-suspended`, which explains that the school is over its limits and that their enrollment is intact.
247
247
- This is a deliberate decision, not an oversight: with no production users yet, real enforcement was shipped directly rather than grandfathering pre-existing over-limit usage.
248
248
249
+
#### Blast radius — the cutoff is tenant-wide and all-or-nothing (issue #517)
250
+
251
+
This is the single most important property of the mechanism and the easiest one to misread from the description above, so it is stated here explicitly.
252
+
253
+
`has_course_access()` gates on `t.access_cutoff_at IS NULL OR t.access_cutoff_at > now()` with **no per-student predicate**. Once the cutoff passes, access is denied for **every student in the tenant, across every course**, regardless of who or what caused the tenant to go over its limit:
254
+
255
+
- A free-plan school (50-student limit) that enrols its 51st student loses course access for **all 51** — not for the one student over the line, and not only for new enrolments.
256
+
- A school over its *course* limit also loses **student** access; the two limits share one cutoff.
257
+
- Restoration is equally all-or-nothing: the moment usage is back under the limit or the plan is upgraded, access returns for everyone at once. There is no partial or staged restoration.
258
+
259
+
What the cutoff does **not** touch:
260
+
261
+
-`enrollments`, `entitlements`, `transactions` and progress records are untouched — nothing is deleted, revoked or refunded. The cutoff is a gate in front of the data, not a mutation of it.
262
+
- Teachers, admins and course authors keep access, via the `is_tenant_staff()` / author escape hatches in the RLS policies — so a school can still fix its content while cut off.
263
+
- Published preview lessons (`is_preview`) stay public, so the school's funnel keeps working.
264
+
- Other tenants are unaffected; `access_cutoff_at` lives on the `tenants` row.
265
+
266
+
**Why all-or-nothing.**#493 §1.1 offered two options — "read-only mode past N days over limit" or "a hard student-count cutoff" — and the hard cutoff is the one that shipped. Narrowing it to only the students over the limit (e.g. the most recently enrolled) was considered and rejected: it would make one student's access depend on the join order of their peers, produce a support burden that is impossible to explain to the student affected, and require a per-student ordering rule inside a `STABLE SECURITY DEFINER` function on the hot path of every content read. The blunt version is legible to the school admin — who is the person who can actually fix it — and is reversible in one action. If per-student narrowing is ever revisited it should be its own issue, not a change smuggled into the enforcement function.
267
+
268
+
#### Notification ladder (issue #517)
269
+
270
+
Because the blast radius is this wide, the school has to be told more than once. #494 sent exactly one email at scheduling time — a design consequence of `decideAccessCutoffAction()` returning `'schedule'` only once per cutoff — with no retry if it failed and no message on the day access actually stopped.
271
+
272
+
-**Ladder:**`scheduled` (immediately, T-14) → `reminder_7d` → `reminder_1d` → `enforced` (the first sweep after access actually stops). `accessCutoffWarningTemplate({ stage, ... })` gives each rung its own subject and copy; the `enforced` notice is written in the past tense.
273
+
-**Ledger:**`access_cutoff_notifications` (migration `20260725100000_access_cutoff_notifications.sql`), one row per `(tenant_id, cutoff_at, stage)` under a unique constraint. Keyed on `cutoff_at` as well as tenant, so a cleared-then-rescheduled cutoff correctly starts a fresh ladder for its new deadline. Service-role only: RLS is enabled with no policies.
274
+
-**Retry:** a ledger row is written **only** when at least one admin address actually received the mail. A rung nobody received stays unrecorded and is therefore still due on the next daily sweep. `dueCutoffNotificationStage()` returns at most one rung — always the most urgent reached-but-unsent one — so an undelivered early rung is superseded rather than queued behind an urgent one (no "you have 7 days" landing beside "access is now paused").
275
+
-**Where it runs:**`app/api/cron/enforce-plan-limits/route.ts` passes `notifyDueStages: true`. No new cron entry was added — the sweep that already visits every tenant daily is exactly the cadence the ladder needs. Its response reports `notified` (per stage) and `notifyFailures`, so a failing mail provider is visible in the cron log instead of silent. Event-driven call sites (join-school, course creation, plan changes) do **not** pass the flag, so user-facing actions never pay email latency for a reminder the cron will send anyway.
276
+
277
+
#### In-app signals (issue #517)
278
+
279
+
-**Tenant admins**: `<AccessCutoffBanner />` (`components/shared/access-cutoff-banner.tsx`) renders in the dashboard shell (`app/[locale]/dashboard/layout.tsx`) for `role === 'admin'`, on every admin page rather than only on billing. Two states — scheduled (days remaining) and active (access paused) — both naming the school-wide blast radius in the same words as the email. Deliberately **not** dismissible: a dismissed countdown to losing all student access is indistinguishable from no countdown. Backed by `describeAccessCutoff()` / `getAccessCutoffNotice()` (`lib/billing/access-cutoff-notice.ts`), a single indexed single-row read gated to admins.
280
+
-**Students**: `/dashboard/student/access-suspended` (shipped in #509, above) is the specific "your school's account needs attention" state — it names the school's plan limits as the cause and states that the enrolment is intact.
0 commit comments