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 undersrc/. 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 oncommon/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.mdis 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'stxclient is not concurrent-safe). - Prisma P2002
meta.targetlimitation atsrc/homerooms/homerooms.service.ts:790–797. - Access-context branch ordering at
src/subject-groups/subject-groups.access-context.ts:13–14(dept-head must precedestaffbecause every dept-head carries the syntheticstaffprofile role). - Permissions catalogue cache TTL rationale at
src/permissions/permissions.service.ts:29–35(why 60s is safe even with temporal role bounds). forwardRefrationale atsrc/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(distinguishesSETUP_STEP_MISMATCHfromSETUP_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 throwsHttpExceptiondirectly across all 31 modules sampled.AllExceptionsFilterpreserves the typedErrorCodeinto the JSON response. Seedocs/06-error-handling.md.CompiledPermissions(scopes: { entity: { scope: ScopeAccess } },actions: { entity: Set<string> }) is the single in-memory permission representation. Memoized once per request onrequest.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.mdcaptures rationale per feature, including non-goals and deferrals. Combined withCLAUDE.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.tsis an actual drift guard: every seedSCOPES[entity]entry must have a matching*_SCOPESruntime constant. See [feedback memoryfeedback_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.tsextraction 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
AppModuleand 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¶
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) | assertCurriculumNotInUseLocked — SELECT … 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
personUuidcross-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.
- Carve helpers out of the largest services at the next iteration of each. The
study-plan-sync.ts/curriculum.validation.ts/declared-subjects.helper.tsprecedent works; apply it tohomerooms(eligibility helper, P2002 dispatcher, response mapper),auth(split profile resolution + cross-tenant credential matching),subject-groups(mirror the homerooms cuts),students(next iteration). - DONE for §4 —
src/common/docs-module-map.drift.spec.tsnow 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. - Define the non-fail-soft cross-module side-effect pattern before the second feature needs it. Today there's
applyDeclaredSubjectsSideEffect(fail-soft) andInvitationInvalidator(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. - 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. - Pin Prisma's major version + add a Prisma-upgrade checklist that re-verifies the P2002 dispatchers (
src/common/utils/prisma-p2002.tsconsumers +homerooms.service.ts:785–813). The regex-on-meta.targetpattern is the canary. - When RLS lands, audit every service method for missing
tenantIdfilters 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.