The failure mode
Kapsül Hafıza teaches cognitive skills — attention, perception, reading speed, memory — to students in grades 1 to 12. The curriculum was mature and proven in person. The delivery was not: instructors ran exercises by hand, scored paper diagnostics manually, and tracked cohorts in spreadsheets.
Turning that into a platform means five roles — student, teacher, manager, staff, admin — sharing one tenant. And it means one uncomfortable property: a single wrong permission leaks a minor's data.
The commercial layer makes it worse rather than better. A family buys a package; a teacher assigns a class exercise; a staff member prescribes something individually. Three different grant mechanisms, all of which have to resolve to the same answer to the same question: may this student open this thing, right now?
Why the obvious fix breaks
The default approach is a role check at the top of each handler and a where clause in each query. It fails in a specific, quiet way.
Mutations are the easy half. There is one obvious place to check, and forgetting usually breaks loudly — someone tries the action, it does the wrong thing, a test or a person notices.
List queries fail in the wrong direction. Forget the ownership predicate and nothing errors. The page renders. The query returns rows. The only symptom is that a teacher can see students who are not theirs, and nobody finds out until someone happens to look. A convention that each query must remember to filter correctly is not a security model; it is a distribution of chances to get it wrong, one per query, forever.
And a role check answers the wrong question anyway. role === 'TEACHER' says what kind of actor this is. The question the entitlement layer has to answer is whether this student may open this exercise given what their family bought, what their class was prescribed, and what was assigned to them individually.
The rule I settled on: make the correct thing the only convenient thing, and make the dangerous case — list queries — impossible to express without the predicate.
The mechanism
A capability registry exposes three primitives, and each one has exactly one job:
// UI affordance. May this actor see the button at all?
can(actor, "exercise.publish");
// Mutation boundary. Throws rather than returning false, so a forgotten
// branch cannot silently continue into the write.
authorize(actor, "exercise.publish", exercise);
// The load-bearing one. Returns the tenant-and-ownership predicate that
// every list query must apply — the filter is produced centrally, not
// re-derived correctly-or-otherwise at each call site.
const rows = await db.query.exercises.findMany({
where: scopedWhere(actor, "exercise.read"),
});scopedWhere() is the one that matters. It converts "remember to filter this list by tenant and ownership" from a thing each author has to know into a thing the query cannot omit without looking obviously wrong in review.
Migrating existing pages onto it was not a tidy-up. It surfaced two real cross-tenant defects that role-only checks had hidden — pages where the role was right, the query was not scoped, and nothing had ever complained. A test now asserts the registry's shape and blocks deploys, so a capability cannot be added without declaring how it scopes.
Entitlements are category-bound and resolved at read time. Packages grant categories rather than fixed lists of exercises. When the team adds an exercise to the attention category, every family who already owns an attention package gets it immediately — no backfill job, no migration, no support ticket. The alternative, granting a snapshot of exercise IDs at purchase, means every content addition creates a support queue.
The gate shipped in shadow mode. It ran in production with enforcement off, reporting what it would have blocked, until the production data proved complete enough to trust. Only then was enforcement switched on. It is on today. A permission gate is one of the few features where being wrong in the safe direction still costs you money — a paying family locked out of what they bought is a refund and a phone call — so finding out on real traffic before enforcing is worth the delay.
The edge cases that shaped it
Exercises are parameterised templates, not content records. Rather than authoring 881 separate exercises, the platform ships 52 interactive React templates and stores each exercise as a template reference plus a parameter set. Staff tune sliders against a live preview and publish. Adding a new drill type is an engineering task; adding a new drill is a two-minute authoring task — and, importantly for the permission model, every one of those 881 exercises inherits its access rules from its category rather than carrying its own.
The same drill is re-parameterised per grade. A 4th-grader and a 9th-grader do the same exercise at different speeds, grid sizes and durations. Lesson videos follow the same rule — one HLS variant per grade band, transcoded by a background worker.
Diagnostics are scored against grade-band norms. A perception score of 62 means something different for a 3rd-grader than for a 7th-grader, so seven test families are scored against grade-band thresholds rather than one universal scale and rendered into levelled narrative reports.
Payment finalisation is race-guarded and idempotent. iyzico completes 3DS and both the callback and the webhook fire. Both paths lead to order finalisation, so finalisation has to tolerate arriving twice, and a reconciliation cron settles orders that got stuck in between.
The pipeline is the safety net. Eleven checks gate every deploy — typecheck, lint, the permission registry assertion, a design-token ratchet, and behavioural tests for the specific regressions that had previously reached users. Migrations apply before the build; the release is blue/green with post-deploy health verification. Deploy time went from 23 to 10 minutes by reusing unchanged worker images, persisting the build cache on a weekly key, and shipping through a registry instead of copying tarballs.
Limits, and what I would do differently
Three primitives is the right number and still one too few. There is no primitive for may this actor see this single record, so single-record reads go through authorize() with a resource argument, which reads slightly wrong at the call site. It works; it is the seam where someone will eventually do the intuitive thing instead of the correct one.
A registry test asserts shape, not correctness. It catches a capability declared without scoping rules. It cannot catch a capability whose scoping rules are wrong. The two defects the migration surfaced were found by reading the queries, not by the test.
Shadow mode ended by judgement, not by threshold. The gate was switched to enforcing when the log looked clean, which is a decision a person made by looking. A defensible version defines in advance what clean means — zero unexpected denials across a full billing cycle, say — so the switch is evidence rather than confidence.
No performance, time-saved or revenue figure is claimed. What is verified: operating in production since March 2026 across all five roles on one tenant, 881 published exercises from 52 templates across six categories, 51 diagnostic assessments with 2,400 authored questions, 226 lesson videos transcoded and serving with zero variants stuck failed or queued, and a live iyzico integration handling real money at early volume. Cohort, pricing and commerce figures are the client's business data and stay with the client.
If multiple roles share one tenant and some of your users are children, the question is not whether your permission checks are correct today. It is whether the next query someone writes can be wrong without anyone noticing.