Sub-Resource Decorator — Simplification Opportunity¶
Status: brainstorm only. Not on a roadmap. Captured here so we don't lose the framing the next time the question comes up.
The observation¶
Routes in the SIS API fall structurally into two shapes:
- Entity CRUD — scope-grouped body (
{ identity: {...}, health: {...} }), scope-grouped response (same envelope), per-entity record-access policy.GET/PATCH /students/:id,POST /students, etc. - Sub-resource / lookup / aggregate — flat body (no scope envelope), flat response (no scope envelope), no per-key field filtering possible.
GET/PATCH /students/:id/curriculum-selection,GET /rooms/types,POST /students/import, command-center dashboards.
The two shapes need different enforcement:
| Concern | Entity CRUD | Sub-resource |
|---|---|---|
| Route gate | @RequireScopes(entity, mode) (any scope on entity) |
@RequireScope(entity, scopeKey, mode) (specific scope key) |
| Body write filter | FieldWriteGuard per-key WRITE check against body's scope-group keys |
FieldWriteGuard only enforces NEVER_WRITABLE_FIELDS (id, createdAt, tenantId, …); per-key check would mis-classify |
| Response filter | FieldFilterInterceptor strips scope groups the user lacks READ on |
FieldFilterInterceptor would strip everything (no top-level key matches a scope name) — must be bypassed via @AggregateResponse |
| Record-access policy | @AppliesPolicy(<Entity>Policy) composes role gate + policy.where(ctx) merge in service |
Same @AppliesPolicy shape, but policy.where(ctx) is sometimes a no-op (dashboards aggregate via per-entity policies inside the query module) |
Today we express the distinction through three orthogonal markers that callers have to combine correctly per route:
@RequireScope(singular) vs@RequireScopes(plural)@AggregateResponsepresent vs absent@AppliesPolicy(policy)vs raw@RequireRoles(...)
Each marker drives one downstream consumer; the three are checked independently by separate guards / interceptors / scanners. They almost always co-vary in practice — a route that needs @RequireScope almost always also needs @AggregateResponse (because its response isn't scope-grouped) — but nothing in the type system or the guard stack expresses that co-variance. The boot-time assertScopeDecoratorExclusivity scanner catches the contradiction case (@RequireScopes + @RequireScope on the same handler) but doesn't catch the missing partner case (@RequireScope without @AggregateResponse on a non-scope-grouped response).
The simplification¶
Replace the three markers on sub-resource / lookup / aggregate routes with a single classifier — call it @SubResource(entity, scopeKey, mode) for the sake of the brainstorm — that wires all three behaviours at once:
// Conceptual shape — not implemented.
@Get(':id/curriculum-selection')
@SubResource(EntityKey.STUDENTS, 'curriculum_selection', 'read')
@AppliesPolicy(StudentsPolicy)
async getCurriculumSelection(...) { ... }
What @SubResource(...) emits in metadata, conceptually:
| Downstream | Effect |
|---|---|
ScopeGuard |
Reads (entity, scopeKey, mode) — identical to today's REQUIRED_SCOPE_KEY behaviour |
FieldWriteGuard |
Skips per-key WRITE check; NEVER_WRITABLE_FIELDS net still fires |
FieldFilterInterceptor |
Pass-through (current @AggregateResponse behaviour) + the runtime safety-net assertion (top-level keys must not collide with the entity's scope names) |
| Boot-time scanners | assertSubResourceShape — if a route carries @SubResource but the return type extends ScopeGroupedDto, fail. Inverse: if a route carries no scope-grouped marker and its return type doesn't extend AggregateResponseDto, fail. |
@RequireScopes (plural) stays for entity CRUD — that's the case the per-key downstream enforcement is built for. The collapse is sub-resource-side only, not whole-stack.
Cost / benefit¶
Benefits
- One marker per sub-resource route instead of three. Routes with
@SubResourcecannot accidentally omit@AggregateResponse(or vice versa) — the co-variance is structural. - Drops one boot-time scanner (
assertScopeDecoratorExclusivity— the mutual-exclusion check becomes "did you put@SubResource+@RequireScopeson the same handler?" which is the same shape but with cleaner-named decorators). - Easier to grep for sub-resource routes (
@SubResource\() — today you'd grep for@AggregateResponseand filter manually for the ones that aren't on entity CRUD imports. - Lookup GETs (
GET /rooms/types,GET /academic-years,GET /curricula/presets) currently use@RequireScopes(plural — coarse "any scope" gate) +@AggregateResponsebecause there's no single specific scope they map to.@SubResourcewould either keep them on@RequireScopes+ a separate@LookupResponsemarker (different concept entirely), or expand the classifier — neither change is free.
Costs
- Decorator surface goes up before it goes down: introducing
@SubResourcemeans N routes to migrate (today: 1 —/students/:id/curriculum-selection). Dashboard routes (completeness,onboarding,study-plan-selections) and lookup GETs would also potentially migrate; the migration list is finite but non-trivial. - The "three markers" shape isn't actually painful in practice — the boot-time invariants catch most mistakes, and there's only one sub-resource route today (curriculum-selection). The simplification is preemptive, not reactive.
- Coupling: today's three markers map cleanly to three independent consumers (
ScopeGuardreads one,FieldFilterInterceptorreads another,RolesGuardreads a third). Combining them into a single marker means a single metadata key drives three consumers — minor coupling, but it's a step away from the orthogonal-layers model the rest of the chapter argues for.
When to revisit¶
The current shape is fine while there is exactly one sub-resource route. Reconsider when any of:
- A second sub-resource route lands (and we feel the friction of remembering the three-marker combination).
- A boot-time scanner catches the "missing partner" case (
@RequireScopewithout@AggregateResponse, or vice versa) — that's the signal the implicit co-variance is hurting. - The dashboard routes'
@AggregateResponse+@AppliesPolicy+@RequireRolescombination grows a fourth orthogonal marker.
Until then, document the co-variance in chapter 04 (already done — see "Gate-only scopes" and "Interceptor Contract") and rely on the boot-time invariants to catch the obvious bugs.
Related¶
- chapter 04 §"Gate-only scopes" — the current
@RequireScope(singular) pattern - chapter 04 §"Interceptor Contract: Scope-Grouped vs Aggregate Responses" — the
@AggregateResponsemechanism - chapter 04 §"Boot-time invariants" — the three scanners that catch the obvious mistakes
docs/superpowers/specs/2026-05-27-students-curriculum-selection-scope-split-design.md— the canonical sub-resource route this analysis is anchored on