Skip to content

Maintainability, Extensibility & Stack-Change Cost

Status — 2026-05-26 audit (refreshed 2026-06-25 — attendance + audit-log shipped; counts and off-axis seams §5.2/§5.3 updated). Coverage: every chapter under docs/, every module under src/. Deep-read files: src/common/services/base-tenanted-crud.service.ts, src/permissions/permissions.service.ts, src/setup/setup.service.ts, src/auth/auth.service.ts, src/students/students.service.ts, src/curriculum/curriculum.service.ts, src/timetable-templates/timetable-templates.service.ts, src/invitations/invitations.service.ts, src/homerooms/, src/subject-groups/. Spot-check on common/utils/, common/decorators/, all module barrels and cross-module imports. The file:line references throughout are evidence; treat them as anchors, not exhaustive.

This chapter is a one-shot audit, not a living invariant doc. Other chapters tell you how to write conformant code. This one tells you how changeable the resulting code is — what is cheap to extend, what is structurally locked in, and what it would cost to swap a piece of the stack.

Read this when: - A stakeholder asks "how hard would it be to move off X?" - You're sizing a refactor and want a calibrated baseline. - You're deciding whether a new pattern deserves its own chapter — §5 lists the off-axis seams where new patterns will have to be invented anyway.


1. Verdict

Dimension Rating One-line
Maintainability High Docs aren't lying; conventions are coherent; risk is service file size + cognitive surface.
Extensibility (on-axis) High New entity / scope / action / role / transport land on paved paths.
Extensibility (off-axis) Medium Non-fail-soft cross-module write coordination, cascade consumers, post-tx fan-out are sparsely covered.
Stack-change cost Bimodal Ports (mailer, storage) → days. Prisma / NestJS → months.

Headline stats

Modules under src/:                  31
Total non-spec TS LOC under src/:    ~34,000
Docs LOC under docs/:                ~8,200 (16 chapters + REFERENCE + epics)
Top services > 1000 LOC:             4   (curriculum, timetable, students, auth)
Top services > 600 LOC:              13
TODO / FIXME / XXX markers in src/:  10   (across 9 files)
Common utilities (one-purpose .ts):  ~40  (each with a paired spec)
Drift specs:                         4    (rbac-catalogue, docs-module-map, error-examples, error-field-path)

2. Maintainability — what's working

2.1 Documentation is load-bearing and accurate

  • docs/REFERENCE.md is a real triage map. §6 ("if you're touching X, open Y first") routes by task type to a minimal file set. §4 (module map) is a one-line responsibility for every module.
  • Per-chapter narratives explain why, not just what. The 16 chapters cross-reference each other consistently; no contradictions surfaced.
  • The modules sampled match the docs 1:1 — module file shape, scope name, RBAC seed entries, error codes, access-context helper shape. Docs are not aspirational fiction.

2.2 Module shape is uniform — with documented variations

Canonical shape: <name>.module.ts, <name>.controller.ts, <name>.service.ts, <name>.queries.ts, <name>.swagger.ts, optional <name>.access-context.ts, dto/, interfaces/, index.ts. Plus 3–5 spec files per module.

Large modules fold sub-resources into subfolders once they pass ~2 sub-resources / ~15 files (dominant resource stays flat at root, satellites get their own folders). Convention adopted 2026-06-08, applied incrementally — not yet on any module. See chapter 05 §1 Foldering large modules.

Documented variations that are intentional, not drift: - Multi-controller modules carve sub-resources into their own controller while sharing the service: curriculum/ (2 controllers — curriculum, selection-window), command-center/ (4 — completeness, onboarding, curriculum-selections, overview), timetable-templates/ (3 — day-templates, week-templates, timetable-assignments), departments/ (2 — departments, grades), students/ (2 — students, curriculum-selection). - Helper sub-files extracted alongside the service for cohesion: curriculum/curriculum-structure-sync.ts, curriculum/curriculum.validation.ts (264 LOC), students/curriculum-selection.service.ts (433 LOC), files/person-document-operations.ts, teachers/declared-subjects.helper.ts, teachers/assert-teachers-in-tenant.helper.ts, homerooms/homeroom-eligibility.ts + subject-groups/subject-group-eligibility.ts (the single-source eligibility classifiers), departments/reorder-in-scope.ts. This is the existing recipe when a service crosses size threshold — see §3.1. - Module exports a structural interface rather than depending on a foreign module type, to break circular deps. Canonical: InvitationInvalidator interface declared in src/common/services/base-tenanted-crud.service.ts:75-83, structurally satisfied by InvitationsService. Allows the base service to invalidate invitations on email change without importing InvitationsModule.

2.3 Comments capture intent, not behaviour

Inline comments concentrate on non-obvious why. Sample:

  • Rename cascade rationale at src/homerooms/homerooms.service.ts:398–411.
  • Sequential-await-on-tx warning at src/homerooms/homerooms.service.ts:405–406 (Prisma's tx client is not concurrent-safe).
  • Prisma P2002 meta.target limitation at src/homerooms/homerooms.service.ts:790–797.
  • Access-context branch ordering at src/subject-groups/subject-groups.access-context.ts:13–14 (dept-head must precede staff because every dept-head carries the synthetic staff profile role).
  • Permissions catalogue cache TTL rationale at src/permissions/permissions.service.ts:29–35 (why 60s is safe even with temporal role bounds).
  • forwardRef rationale at src/permissions/permissions.service.ts:44–47 (one-directional circular dep, DI resolution order).
  • Timing-attack mitigation hash at src/auth/auth.service.ts:49–51 (burns argon2 time on email-not-found to prevent enumeration).
  • Concurrent-advance handling at src/setup/setup.service.ts:202–222 (distinguishes SETUP_STEP_MISMATCH from SETUP_STEP_CONCURRENT_ADVANCE — the two codes triage differently).
  • Best-effort draft save during back-navigation at src/setup/setup.service.ts:244–256 (why validation failures are swallowed).
  • Recipient → profile mapping helper at src/invitations/invitations.service.ts:67–82 (why one map covers two consumption sites).

None restate what the code does. Every comment above would otherwise rot into archaeology.

2.4 common/ is densely factored

src/common/utils/ ships ~40 single-purpose utilities, each with a paired spec. Sample of the load-bearing ones:

Utility Used by
pickDefined Every service building Prisma data payloads from optional DTO fields
syncEntities Curriculum, timetable, academic-years (generic child-collection sync)
runBulkSyncTx Wizard bulkSync paths across multiple modules
twoPhaseReorder Timetable templates (slot ordinal reindex), departments/grades (reorder in scope)
assertYearWritable BaseTenantedCrudService + every import pipeline
assertYearIsDraft Academic-years CRUD (DRAFT-only mutation paths)
resolveActiveYear Every year-scoped read; lifecycle hook in BaseTenantedCrudService.create
computeMissingFields / computeMissingFieldsAcrossScopes Profile completeness + command-center completeness tab
assertNoPeopleDuplicate / resolvePersonUuid Student/Teacher/Staff single-create + import paths
assertReferentCanWrite Students update + curriculum-selection write paths
narrowRolesForActiveProfile Auth token issue + refresh (active-profile narrowing)
afterCommitQueue Invitations service (fire-and-forget mailer dispatch after tx commit)
scrubSensitiveQueryParams AllExceptionsFilter (PII-safe error logging)
groupByDeptCurriculumGrade Homerooms list response (3-level tree)
flattenValidationErrors Setup wizard step DTO validation

This is the largest single positive signal in the codebase: shared concerns extracted into small, testable, named functions. Reading them, you can predict where to put the next utility without asking.

2.5 Typed errors and typed permissions

  • AppException(code, message, status, { params, data }). Domain code never throws HttpException directly across all 31 modules sampled. AllExceptionsFilter preserves the typed ErrorCode into the JSON response. See docs/06-error-handling.md.
  • CompiledPermissions (scopes: { entity: { scope: ScopeAccess } }, actions: { entity: Set<string> }) is the single in-memory permission representation. Memoized once per request on request.permissions. Every guard and the interceptor read from the memoized result — exactly one permission DB query per request.

2.6 Co-located design specs + drift guards

  • docs/superpowers/specs/YYYY-MM-DD-<slug>-design.md captures rationale per feature, including non-goals and deferrals. Combined with CLAUDE.md § Design Gate, architectural decisions are reviewable artefacts, not Slack messages. The spec corpus has roughly doubled since the audit date; recent additions include the attendance and audit-log design specs, alongside the older invitations, evaluation-scales, RUS-4 selection, US-32 v1 + v2 selection-window, US-33/US-33.1, timetable-templates, preset-management-roles, and option-block subject merge designs.
  • src/permissions/rbac-catalogue.drift.spec.ts is an actual drift guard: every seed SCOPES[entity] entry must have a matching *_SCOPES runtime constant. See [feedback memory feedback_rbac_drift_check].

2.7 Lifecycle hooks are explicit, not implicit

OnModuleInit is used sparingly and only where it earns its keep. Example: InvitationsService implements OnModuleInit at src/invitations/invitations.service.ts:85 — used to wire up the AfterCommitQueue + the post-tx mailer-dispatch loop. The hook is named, not magic.


3. Maintainability — pressure points

3.1 Service file size is past the documented threshold for most major services

docs/01-architecture.md §5 calls out:

If a service grows beyond ~15 methods or contains complex orchestration, consider extracting a repository for that specific module.

State of the codebase against that threshold:

Service LOC
curriculum/curriculum.service.ts 1529
timetable-templates/timetable-templates.service.ts 1500
students/students.service.ts 1362
auth/auth.service.ts 1061
invitations/invitations.service.ts 985
subject-groups/subject-groups.service.ts 953
files/files.service.ts 900
homerooms/homerooms.service.ts 854
curriculum/study-plan-sync.ts (already extracted) 842
rooms/rooms.service.ts 803
teachers/teachers.service.ts 792
academic-years/academic-years.service.ts 788
custom-fields/custom-fields.service.ts 779
common/services/base-tenanted-crud.service.ts 1023

13 services past the threshold; one foundational service (base-tenanted-crud) at 1000+ LOC. Recipe for size relief already in use (helper sub-files; see §2.2) — the question is when to apply it, not how. Candidates already visible in the audited code:

  • Homerooms: assertStudentsEligible (:691–769), mapWizardP2002Target (:785–813), toDetailDto (:817–847).
  • Curriculum: study-plan-sync.ts extraction is the precedent — the next cut is per-feature (selection-invalidation cascade, preset expansion, bulk sync) into sibling helper files.
  • Auth: profile resolution + cross-tenant credential matching + JWT issue/refresh are three distinct sub-domains co-located in one 1061-line service.

BaseTenantedCrudService is the riskiest single file in the codebase by virtue of blast radius, not LOC alone: a regression there breaks students + teachers + staff simultaneously. Coverage is high (1011-line spec) but every change still warrants a careful review.

3.2 Cognitive surface area

Decorator vocabulary a contributor must learn before landing a non-trivial PR:

@ProtectedResource   @RequireScopes    @RequireAction   @RequireRoles
@AccessContext       @AggregateResponse @TenantId       @AcademicYear (where present)
@ApiResourceController + the per-entity Api* swagger helpers

Plus the mental model in 04 §Mental Model: four layers of access control, guard chain order, scope-grouped vs aggregate response distinction, active-profile narrowing, dept-head parameterised-role record narrowing, action-embeds-scope-requirements. All coherent, all documented. Onboarding cost: ~1 week before independent non-trivial work is safe.

The @AggregateResponse() foot-gun (response stripped to {} when the marker is missing on a non-scope-grouped endpoint) is documented at docs/04-rbac.md "Interceptor Contract" — the doc spends ~50 lines explaining why and providing a runtime safety net. The fact that it needs that much explanation is the canary: powerful conventions become foot-guns when half-learned.

3.3 Manual tenant filtering does not fail closed

Per docs/02-multitenancy.md and the trade-off acknowledged in docs/01-architecture.md §5, every service method explicitly includes tenantId in where. RLS is deferred to Phase 2 as a safety net.

A new service method written without tenantId could silently leak cross-tenant. The drift spec, EvaluationScale's named exception (tenantId IS NULL for platform-owned presets), and the visibility helpers mitigate but do not eliminate this. The mitigation that would eliminate it (PostgreSQL RLS) is on the roadmap, not in the tree.

3.4 Regex-based Prisma P2002 dispatch

mapWizardP2002Target (src/homerooms/homerooms.service.ts:785–813) uses /student_?id/i and /name/i regex tests against meta.target strings to dispatch to typed domain errors. Fragile to Prisma major-version upgrades (the meta.target payload shape has changed across versions; the driver-adapter driverAdapterError.cause.constraint.fields alternative is a different shape entirely).

Same pattern appears with the shared mapP2002 helper at src/common/utils/prisma-p2002.ts, consumed by multiple modules. Pinning Prisma's major version + adding a "Prisma upgrade" checklist that re-verifies these dispatchers is the lightweight mitigation.

3.5 Cross-module foreign-service injection is widespread

docs/REFERENCE.md §5 says:

Do not call a foreign service directly — import the module in AppModule and expose functionality through its public DTO/interface contract.

This is honoured (modules export via barrel, consumers import via barrel; no reach-into-internals). But the graph of who-injects-whom is non-trivial:

Service Injects
AuthService Prisma, JwtService, ConfigService, PermissionsService, FilesService
InvitationsService Prisma, MailerPort (token), AuthService, ConfigService
SetupService Prisma, CurriculumService, StudentsService, AcademicYearsService, step-handler registry
CurriculumService Prisma, EvaluationScalesService
StudentsService (extends BaseTenantedCrudService → Prisma, CustomFieldsService) + FilesService
Command-center services Prisma, AcademicYearsService, CustomFieldsService (per service)
PermissionsService Prisma, CustomFieldsService (forwardRef — one-directional cycle)
BaseTenantedCrudService Prisma, CustomFieldsService (structurally via InvitationInvalidator)
AttendanceService Prisma, AuditService (+ reads timetables.queries cross-module)

The new AttendanceService → AuditService edge is a different flavour from the rest: AuditService is the first generic cross-cutting service every future write-path is likely to inject (append a record inside the caller's tx), not a domain-to-domain coupling.

This is fine for a monolith. It is a problem the day someone tries to extract a module into a separate service (docs/01-architecture.md §1: "Modules map 1:1 to future services"). AuthService injecting FilesService for school-logo embedding on the login response is convenience that bound auth to files; extracting auth means re-deciding how to fetch the logo. Similar for InvitationsService → AuthService, SetupService → CurriculumService + StudentsService, command-center → AcademicYears + CustomFields.

Not a fire today. Worth surfacing because the "module ≈ future service" claim is one of the architecture's load-bearing rationales.

3.6 TODO accumulation is not a problem today

10 TODO/FIXME/XXX markers across 9 files in src/. Tracked locations:

  • src/students/students.queries.ts — narrow teacher-visibility to class/department (waiting on class-models work).
  • src/homerooms/homerooms.service.ts:790@TODO(prisma-meta) on the P2002 dispatcher limitation.
  • src/subject-groups/subject-groups.service.ts.
  • src/staff/staff.service.ts.
  • src/teachers/teachers.service.ts.
  • src/teachers/dto/scopes/teacher-identity.dto.ts.
  • src/command-center/study-plan-selections.service.ts.
  • src/common/utils/import-code-generator.ts, import-row-mapping.ts.

Each is explicit and traceable. No central tracking, but the volume is too low to warrant infrastructure. Re-check at the next audit.

3.7 Doc volume is approaching its own maintenance cliff

~8,200 lines across docs/ (16 chapters + REFERENCE + epics)

The REFERENCE.md triage rule is what makes this navigable. If its §6 file index falls out of sync with reality — module renamed, scope key changed, file path moved — the docs become misleading rather than missing, which is the worse failure mode. Two drift specs now guard against this: rbac-catalogue.drift.spec.ts (catalogue ↔ runtime constants) and src/common/docs-module-map.drift.spec.ts, which asserts every src/<module>/ folder has a §4 module-map row in REFERENCE.md (the gap this section originally flagged is closed for §4). Not yet covered: the §6 / §8 file-index entries — a CI grep that the chapter filenames listed there actually exist would close the remaining slice.


4. Extensibility — on-axis (cheap, paved)

The abstractions anticipate these change shapes. Adding any one is a checklist, not a redesign.

4.1 Add a field to a scope

docs/11-workflows.md §1. Five-line edit across scope-fields.ts + the relevant scope DTO + (if FK) queries.ts. FieldFilterInterceptor and FieldWriteGuard pick it up automatically.

4.2 Add a new scope to an entity

docs/11-workflows.md §2. DTO under dto/scopes/, append to create/update/response DTOs, scope-fields constant, getScopeFieldMappings(), RBAC catalogue seed entry, role grants in roles.ts. ~7 file edits; the drift spec catches misses.

4.3 Add a new entity

docs/11-workflows.md §3. New src/<domain>/ folder following the canonical shape, schema model, EntityKey constant, AppModule wire-up, RBAC seed entry. The Design Gate (CLAUDE.md) forces a written spec first; cost is "design + ~10 file edits" not "open-ended hacking."

4.4 Add a new role

docs/04-rbac.md §Record-Level Access. One new branch in each <entity>ForAccessContext helper that the role can read; makeRoleVisibilityResolver factors the boilerplate. The decorator-filter synchronization invariant (§4.1.1 of that chapter) locks @RequireRoles(...) to the helper's branch list; tests catch the mistake.

4.5 Add a storage / mailer transport

Ports exist and are wired via env var: - src/files/storage/file-storage.port.ts — implement port + register transport. - src/mailer/mailer-port.interface.ts + MAIL_TRANSPORT env var. In-tree transports: log-mailer.transport.ts, memory-mailer.transport.ts, resend/ submodule with webhook receiver.

Days, not weeks. No schema migration, no permission impact.

4.6 Add a setup wizard step

src/setup/setup.service.ts is 384 lines because per-step load/save/mapper logic is delegated to a step-handler registry (StepHandler interface + StepHandlerRegistration[] injected via the STEP_HANDLERS token). Adding a step: 1. Add the enum value to setup-steps.ts. 2. Implement a StepHandler in step-handlers/ with load, save, isComplete, and optional DTO class. 3. Register in the providers array. The orchestrator (handleForwardNavigation / handleSameStepSave / handleBackNavigation) doesn't need to know about the new step. See docs/08-setup-wizard.md.

4.7 Add an action with scope requirements

Action requirements are declarative via ActionScopeRequirement rows in the RBAC catalogue. The compilation pipeline in PermissionsService.computeEffectiveActions (src/permissions/permissions.service.ts:235–261) automatically filters granted actions to only those whose scope requirements are met. No code change needed at the consumption site.


5. Extensibility — off-axis (where new patterns will have to be invented)

These dimensions have no canonical recipe yet. The first feature needing one defines how the next ten do it.

5.1 Non-fail-soft cross-module write coordination

applyDeclaredSubjectsSideEffect (src/teachers/declared-subjects.helper.ts, called from src/homerooms/homerooms.service.ts:322 and the subject-groups create path) is fail-soft by design — if the caller lacks teachers.employment:write, the side-effect is logged and skipped. The parent operation succeeds either way. docs/14-homerooms-subject-groups.md §3 documents the rationale.

The codebase has no recipe for the opposite shape: a cross-module side-effect that must fail loud and roll the parent back. The first feature requiring it has to decide: - Foreign module's service called inside the parent's $transaction (couples error semantics, requires Prisma.TransactionClient to be passed across boundaries — which already happens for InvitationInvalidator.invalidateByRecipient but only for that one case) or after (loses atomicity). - How FieldWriteGuard-style permission checking composes with the parent's already-passed guards. - Naming convention (applyX vs enforceX vs requireX).

5.2 Post-transaction fan-out is partly covered

src/common/utils/after-commit-queue.ts is the post-tx primitive — currently used by InvitationsService to enqueue outbound mail dispatch after the tx that creates/updates the invitation row commits. This avoids "mail sent for a row that rolled back."

What it does not cover yet: - Cross-module notification ("homeroom student moved" → notify attendance, timetable, evaluation surfaces). There is no event bus; BullMQ is deferred (docs/01-architecture.md §3 Phase 2). - Cache invalidation triggers (no Redis cache yet, but the Phase 2 plan calls for one).

Audit logging is now shipped, but as an in-transaction synchronous write, not a post-tx fan-out: AuditService.record(tx, actor, params) (src/audit-log/audit.service.ts:39–58) appends one append-only audit_logs row inside the caller's transaction (actor snapshot resolved once per request via resolveActor, full data snapshot, soft entityId that survives delete). Attendance already consumes it (src/attendance/attendance.service.ts calls this.audit.record(tx, …) on take/update). Because it rides the caller's tx rather than an event bus, the "no event bus / BullMQ deferred" point above still stands for true cross-module notification fan-out.

Until those land, every consumer is an inline call in the originating transaction, and the cascade grows.

5.3 The homeroom-bound child-SG cascade is load-bearing

docs/14-homerooms-subject-groups.md §3 + §6. Today this is one $transaction block in one place. Attendance is now a shipped consumer of the cohort — read-side, loading homeroom / subject-group / grade-group rosters lazily off the PUBLISHED timetable (src/attendance/attendance.queries.ts: loadHomeroomRoster, loadSubjectGroupRosterIds, loadGradeGroupRoster, loadPublishedTimetableId). The remaining hypothetical consumers (timetable write-side, evaluations, future report-card surface) will need to plug into the same orchestration. Each new consumer doubles the surface area of "what happens when a student moves between homerooms" — attendance landing reinforces the point rather than retiring it.

A domain-event hook (HomeroomStudentMoved, HomeroomCreated) would absorb this — see §5.2.

5.4 Homeroom header-field immutability

docs/14-homerooms-subject-groups.md §7 — Homeroom.{departmentId, curriculumId, gradeId} immutable post-creation. src/homerooms/homerooms.service.ts:354–363 enforces it with HOMEROOM_FIELD_IMMUTABLE. Deliberate product call, not debt.

If product reverses the call: re-seeding child SGs (a different curriculum / grade / track yields a different mandatory-subject set), re-validating the entire roster, cascading the change into every downstream consumer. Small project, not a feature. Worth surfacing if product asks "can we just let admins edit the grade?"

5.5 Aggregate-response opt-out is a perennial foot-gun

Adding a non-CRUD endpoint to an entity controller requires @AggregateResponse() and extending AggregateResponseDto. Forgetting the marker → response stripped to {} for tenant users (platform admins bypass the interceptor, masking the bug in dev). The runtime safety-net assertion in FieldFilterInterceptor catches it loudly in dev/test but only logs in production. Surface grows linearly with non-CRUD endpoints; planned scope-groups refactor (ClickUp 86c8rqer6) will need to migrate the dozen-ish current users.

5.6 Cross-module DI graph couples the "modules ≈ services" claim

See §3.5. Extracting any of students, curriculum, auth, invitations, setup, or any command-center service into a separate process today requires either: - Re-implementing the injected dependency as a remote call (RPC, HTTP), or - Co-locating the dependency into the same extracted service.

Neither is hard individually; the cost is that the extraction order matters and isn't documented. A "module dependency graph" diagram in docs/01-architecture.md would surface this — at the audit date it doesn't exist.

5.7 Curriculum-write concurrency: the constraint / lock / sweep matrix

The curriculum grid remodel (2026-06-04) settled the write surface into three named concurrency strategies. Record them here so future mutators inherit the decision instead of re-deriving an ad-hoc P2002 catch each time. Pick by asking "is there a clean unique to lean on?":

Strategy When to use Mechanism Sites
Constraint-backed A clean DB UNIQUE exists for the invariant Rely on the unique; map the P2002 to a typed 409 via mapP2002 / the module dispatcher Homeroom name (@@unique([academicYearId, gradeId, name])); homeroom-per-student (HomeroomAssignment.studentId @unique); SG-per-subject (SubjectGroupAssignment @@unique([studentId, curriculumSubjectId, academicYearId]), the denorm added by the remodel); SG name (@@unique([academicYearId, gradeId, curriculumSubjectId, name]))
Lock-backed The invariant is a count-then-mutate with no clean unique (a read decides whether the write is legal) assertCurriculumNotInUseLockedSELECT … FOR UPDATE row lock on the curriculum inside the tx, then the count, then the mutate Curriculum demote READY→DRAFT and delete while confirmed selections reference it (CURRICULUM_IN_USE_BY_SELECTIONS); shared across demote / remove / bulkSync
Revalidation sweep A structural edit can silently invalidate other rows that no unique/lock can express Post-mutate revalidateConfirmedSelectionsForCurriculum in the same tx, after the structure sync — replays every confirmed selection and resets confirmedAt on those that no longer satisfy the curriculum Every grid family-sync PATCH /curricula/:id mutation path

Why three and not one: a unique is the cheapest and race-free, so prefer it (the remodel added the SG-per-subject denorm specifically to move that invariant from service-read into this column). Where the legality is a count ("are there selections?") there's no unique to add, so a FOR UPDATE lock serialises the check-then-act. And where a structural edit's blast radius is "some unknown set of confirmed selections elsewhere," neither a unique nor a lock expresses it — the sweep replays and repairs. The regex P2002 dispatchers (§3.4) are the fragile seam of the constraint-backed row; the SG dispatcher already had to be reordered to test the name token before curriculum_subject_id once the SG-name index started carrying both columns — a concrete instance of the §3.4 fragility, mitigated by the per-suite e2e.


6. Stack-change cost — component-by-component

Component Effort Cost driver
Storage (R2 → S3 / GCS / Azure Blob) Days FileStoragePort already abstracts it. New transport + register + env var.
Mailer (Resend → SendGrid / Postmark / SES) Days MailerPort + MAIL_TRANSPORT env var. Resend webhook is isolated under src/mailer/resend/.
Hosting (Railway → Fly / Render / AWS App Runner) Days NestJS runs in a standard Docker container. Acknowledged in docs/01-architecture.md §3. Watch railway.toml preDeployCommand semantics.
Auth provider (Passport+argon2 → Auth0 / Clerk / Supabase Auth) 1–2 weeks Contained in src/auth/. JWT payload shape leaks into request.user typing. argon2 is a Node-native module — moving to an edge runtime (Bun, Deno, Workers) breaks this binding.
Logger (nestjs-pino + CLS → ?) 1–2 weeks CLS uses Node async_hooks; not portable to non-Node runtimes.
Database engine (Postgres → CockroachDB / Aurora-PG / Yugabyte) Weeks Prisma abstracts most of it. Watch JSONB (customFields on every people entity), the deferred RLS plan (Postgres-only), $queryRaw callsites, the partial unique indexes (active_year_unique, user_roles_unique_when_no_department — Postgres-flavoured).
ORM (Prisma → Drizzle / Kysely / TypeORM) Months Prisma is the contract boundary, not a hidden detail. Every queries.ts uses Prisma.<X>GetPayload<{ include: typeof K }> as the canonical row type. Every <entity>ForAccessContext returns Prisma.<Entity>WhereInput. The InvitationInvalidator interface takes Prisma.TransactionClient. Effectively rewrite every service + every queries file + every access-context helper.
Framework (NestJS → Fastify / Hono / Express) Months Guards, interceptors, DI tokens, decorators, @ProtectedResource() composition, metadata reflection (@RequireScopes, @RequireAction, @RequireRoles), step-handler registry pattern, forwardRef() cycle handling. Modules-as-bounded-contexts survives; ~60–70% of wiring code does not.
Runtime (Node → Bun / Deno / Workers) Months Node-native bindings: argon2, nestjs-pino CLS via async_hooks, @aws-sdk/* (varies by runtime support), bullmq (if added).
Frontend stack N/A Backend exposes OpenAPI; FE is fully decoupled.

6.1 What survives any re-stack

The portable artefact is the semantic model, not the code:

  • Multitenancy invariants (02).
  • Entity-Scope-Action RBAC, field-level read filtering, write rejection (04).
  • Academic-year snapshot pattern with personUuid cross-year linking (01 §6).
  • Setup-wizard step-handler registry pattern (08).
  • Wizard-cascade + record-level visibility shape (14).
  • The per-feature design specs under docs/superpowers/specs/.

A re-stack onto Fastify+Drizzle keeps all of this. You'd be re-wiring, not re-deciding. A naive port would lose the model; this codebase's doc artefacts make that loss avoidable.

6.2 Honest re-stack estimate

Re-stack to Fastify + Drizzle + Postgres at feature parity, competent 2–3 person team that preserves the model: 3–4 months. Anyone quoting faster is selling something.


7. Recommendations (in priority order)

Not a roadmap — a punch list. Each is bounded, none are urgent.

  1. Carve helpers out of the largest services at the next iteration of each. The study-plan-sync.ts / curriculum.validation.ts / declared-subjects.helper.ts precedent works; apply it to homerooms (eligibility helper, P2002 dispatcher, response mapper), auth (split profile resolution + cross-tenant credential matching), subject-groups (mirror the homerooms cuts), students (next iteration).
  2. DONE for §4src/common/docs-module-map.drift.spec.ts now guards the REFERENCE.md §4 module map ↔ src/ layout. Remaining slice: a minimal CI grep verifying the chapter filenames listed in REFERENCE.md §6 + §8 actually exist.
  3. Define the non-fail-soft cross-module side-effect pattern before the second feature needs it. Today there's applyDeclaredSubjectsSideEffect (fail-soft) and InvitationInvalidator (in-tx, structural interface for circular-dep avoidance) — but no recipe combining "must succeed AND must not import the foreign module." The next contributor will invent one; better if the design gate captures it first.
  4. Add a "module dependency graph" diagram to docs/01-architecture.md. Strengthens the "modules map 1:1 to future services" claim and surfaces the extraction-order question (§3.5 + §5.6) before it bites.
  5. Pin Prisma's major version + add a Prisma-upgrade checklist that re-verifies the P2002 dispatchers (src/common/utils/prisma-p2002.ts consumers + homerooms.service.ts:785–813). The regex-on-meta.target pattern is the canary.
  6. When RLS lands, audit every service method for missing tenantId filters before turning it on as defense-in-depth — RLS will silently break a method that was leaking before it silently protected it.

8. Notes on coverage

This audit read every chapter under docs/ and inventoried every module under src/. Deep-read sample (named in the status note at the top) was chosen for breadth: the canonical CRUD ref (students), the largest domain service (curriculum), a heavily concurrent service (auth), a lifecycle-hook service (invitations), the spine of CRUD (BaseTenantedCrudService), the RBAC spine (PermissionsService), the state machine (SetupService), the two modules with novel scope shapes (homerooms + subject-groups), and the common utilities and decorators.

What this audit did not do: read every test spec, audit migration history, profile runtime behaviour, run static analysis. Conclusions are file-shape and pattern-level, not behavioural. If a recommendation hinges on something testable (RLS rollout, Prisma upgrade), the recommendation is to test it then, not now.