Skip to content

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:

  1. Entity CRUD — scope-grouped body ({ identity: {...}, health: {...} }), scope-grouped response (same envelope), per-entity record-access policy. GET/PATCH /students/:id, POST /students, etc.
  2. 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)
  • @AggregateResponse present 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 @SubResource cannot 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 + @RequireScopes on 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 @AggregateResponse and 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) + @AggregateResponse because there's no single specific scope they map to. @SubResource would either keep them on @RequireScopes + a separate @LookupResponse marker (different concept entirely), or expand the classifier — neither change is free.

Costs

  • Decorator surface goes up before it goes down: introducing @SubResource means 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 (ScopeGuard reads one, FieldFilterInterceptor reads another, RolesGuard reads 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 (@RequireScope without @AggregateResponse, or vice versa) — that's the signal the implicit co-variance is hurting.
  • The dashboard routes' @AggregateResponse + @AppliesPolicy + @RequireRoles combination 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.