SIS Backend — Open-Items Audit — 2026-07-03¶
What this file is. The single consolidated audit of everything still open in the SIS backend, merging the prior architecture-flaw and quality/convention audits into one open-items list. It is not a closure ledger: remediated findings are dropped entirely, there are no "done" marks, and there is no history section. Every claim is grounded in working-tree source (
file:line); line numbers were accurate at audit time — verify against the current tree before quoting, they shift.Scope. This pass deliberately excludes two axes tracked in their own files and left untouched here: test coverage (
2026-07-02_test-cov.txt,2026-07-02_tests-todo.md) and the architecture judgment narrative (2026-07-02_architecture-judgment.md). Missed-tests / e2e-infrastructure findings are therefore not restated below.Severity (blast-radius × likelihood, weighted for this system — multi-tenant, children's PII, GDPR-relevant): serious = structural debt with real correctness, security-regression, or scaling consequences · latent = real but currently fenced by other factors · medium = maintainability/contract cost with a coordination dependency · low = polish / convention-drift with real-but-small cost. Architecture anchor #5 keeps its original non-contiguous number so the wave specs and memory keep pointing at it; the quality backlog is an unnumbered per-area list. (R4 + R6 were remediated 2026-07-06 and dropped per the open-items policy — spec
docs/superpowers/specs/2026-07-02-school-relationship-flags-ay-create-contract-design.md.)
Part 1 — Architecture flaws (open)¶
One architecture finding remains open. The security/integrity boundaries themselves are now enforced structurally (RLS on all tenant-bearing tables as app_user, boot/CI drift guards, DB-backed uniqueness), so what is left is not a boundary-enforcement gap: one serious module-coupling/scaling cliff. (The two latent data-contract footguns previously tracked here — R4 school-relationship flag divergence, R6 AY create-pin asymmetry — were remediated 2026-07-06 and dropped; spec docs/superpowers/specs/2026-07-02-school-relationship-flags-ay-create-contract-design.md.)
| # | Flaw | Severity | Anchor evidence |
|---|---|---|---|
| 5 | Cross-module mutations run as one synchronous transaction | serious (5/10) | curriculum.service.ts, students/class-selection-sync.ts, curriculum/selection-consistency.ts, prisma.service.ts |
5. Cross-module mutations run as one synchronous transaction — serious (5/10)¶
The flaw. A curriculum edit, a setup bulkSync, a homeroom/SG create, and a selection write each fan out synchronously across 4 modules inside one interactive Prisma transaction, all "required" (any failure rolls the parent back — none offloaded to the afterCommitQueue, which is used only for invitation mail). The worst case — setup bulkSync — is O(curricula × selections): it loops every curriculum, takes FOR UPDATE, captures pre-images of every selection, syncs structure, replays findSelectionInconsistencies over every selection, and seeds SGs — touching students, subject_groups, and homerooms tables, all in one transaction.
Blast radius. (1) Scaling cliff: transaction size is unbounded by data; a large secondary school finalizing setup or editing a heavily-enrolled curriculum hits the transaction-timeout ceiling and the entire edit aborts with no partial-progress path. (2) Architectural blocker: curriculum ↔ students ↔ subject-groups ↔ homerooms is one consistency boundary today — it cannot be split into independent services (the stated "modules → microservices" goal) without a distributed-transaction/saga layer.
Concrete failure scenario. A tenant with ~2,500 enrolled students under one curriculum; an admin renames a subject and re-levels HL→SL via PATCH /curricula/:id. The handler locks the curriculum, captures pre-images for all 2,500 selections, syncs structure, then replays consistency over all 2,500 plus a committed-substance diff, then seeds SGs — exceeding the interactive-transaction timeout. Prisma aborts (P2028), the edit rolls back, and concurrent selection writes were blocked behind FOR UPDATE the whole time. Retry hits the same wall.
Evidence.
- Forward sync writes a selection from inside the homeroom/SG transaction; backward prune deletes class memberships from inside the selection transaction — src/students/class-selection-sync.ts.
- Post-mutate sweep replays over every selection — src/curriculum/selection-consistency.ts.
- Grid PATCH cascade and the bulkSync loop (O(curricula × selections)) — src/curriculum/curriculum.service.ts.
- Transaction budget is raised to 30s timeout / 10s maxWait precisely because bulkSync was aborting under the 5s default — src/prisma/prisma.service.ts.
- afterCommitQueue exists but is used only for mail — src/common/utils/after-commit-queue.ts.
- The team's own maintainability chapter acknowledges the coupling and proposes a domain-event hook — docs/16-maintainability.md.
Honest mitigations. Correctness under concurrency is sound (FOR UPDATE on edits, FOR SHARE on selection writes serialize the race the sweep depends on) — this is a latency/abort + evolvability risk, not data corruption. The READY→READY seed is diff-restricted to newly-offered cells.
Fix (descoped to the canary). Near-term: ship a selection-count scaling e2e so the cliff shows up in CI. Deferred until the scale trigger fires: page the selection replay; move the idempotent sweep/seed into an afterCommitQueue task that re-opens short per-batch transactions; introduce the SelectionChanged / HomeroomStudentMoved domain-event seam (even synchronous in-process) — the seam a future service split needs. Governing spec: docs/superpowers/specs/2026-06-25-wave4-domain-event-seam-design.md (Draft; Q1 replay-vs-write concurrency and Q2 eventual-consistency-window acceptability unresolved).
Still-valid disconfirmations (checked, not flaws)¶
- Raw SQL is tenant-safe. All
$queryRaw/$executeRawsites carrytenant_idin the WHERE; the RLS backstop (enforced at the DB forapp_user) is the load-bearing control the app-layer guard cannot provide. - Attendance / audit-log FK-less snapshots are correct. The plain-column-no-FK design on
AttendanceRecordcontext columns and the softentityIdonAuditLogare intentional immutability guarantees. update/removedo not retarget the year. They act on the record's ownacademicYearId(fetched by{ id, tenantId }); onlycreatepins to ACTIVE (now a documented contract — former R6).- The RBAC runtime path is fail-closed.
FieldFilterInterceptor's one fail-open branch is unreachable on correctly-decorated routes (the guard populates permissions first).
Part 2 — Quality / convention backlog (open)¶
All LOW unless marked. These are drift-away-from-convention polish items — no cross-tenant leak, missing record filter, or broken write gate is among them. The one medium is the FE-coordinated route rename.
Cross-module / architecture¶
- [medium · FE-coordinated] Three modules mount controllers under
/students; correctness rests on a commented import order.src/homerooms/eligible-students.controller.tsandsrc/subject-groups/eligible-students.controller.tsboth@Controller('students')with literal@Get('eligible-for-*'), whilestudents.controller.tshas@Get(':id');src/app.module.ts:103-111carries a comment requiring both picker modules be imported before StudentsModule (route-registration order) or Express 5 captures the literals with:id. Only the existing e2e keeps this from being higher severity. Fix: give the pickers a non-colliding prefix (e.g.@Controller('homerooms') @Get('eligible-students')) — FE-coordinated if the path changes; otherwise treat "no two modules share a:paramroute prefix" as an architectural rule. - [bad-practice] Cross-module deep imports bypass the barrel even though the symbol is on it.
auth/auth.service.ts:18(../files/files.service),permissions/permissions.service.ts:4(../custom-fields/custom-fields.service), andcurriculum/curriculum.queries.ts:5-6(evaluationScaleRefSelect/subjectLevelRefSelectfrom foreign.queriesfiles). (Thereferents → ../students/school-relationships.recomputedeep import was removed 2026-07-06 when that module was deleted — audit R4.) Fix: repoint to the barrels. Ano-restricted-importsrule banning../*/*.{service,queries,policy,mapper}from outside the owning folder would make this self-enforcing. - [missed-pattern]
tenantIdread inconsistently —@TenantId()in most controllers,req.user.tenantIdin a few.permissions.controller.ts,roles.controller.ts,role-assignments.controller.ts, andsetup.controller.tsreadreq.user.tenantIddirectly via@Req();@TenantId()is the documented single extraction site used everywhere else. Both are type-safe — pure drift. Fix: migrate the four to@TenantId()when next editing them (auth.controller.tslegitimately needs@Req()). - [missed-pattern] Setup
bulkSynctransaction convention diverges across the wizard.departments.service.tsuses the sharedrunBulkSyncTx;rooms.service.ts:476andcurriculum.service.ts:393hand-roll$transaction+ their own P2002 catch;school.service.tsuses none. Fix: converge rooms/curriculum ontorunBulkSyncTx, or at least route their duplicate-key mapping throughmapP2002. - [potential-bug]
school.bulkSyncwrites the school row then the logo file with no enclosing transaction.src/school/school.service.ts:172-185—school.upsert(...)then a separateawait applyLogo(...)(storage + FK update), yetapplyLogoJSDoc says "replace it atomically." A logo-apply failure leaves the scalar fields committed — the only setup write path with zero transaction. Blast radius small (per-tenant singleton, wizard-driven). Fix: wrap the pair in one$transaction, or drop the "atomically" wording. - [missed-pattern]
mapP2002typed-409 strategy adopted in only some unique-constraint modules; the rest race read-then-check.evaluation-scales,subject-levels,room-types,timetable-templatesstill do read-then-check and fall back to the filter's genericCONFLICT { entity }on the race. Fix: prefermapP2002when next touching those write paths so the race closes and the typed code survives (see also SUBJECT_LEVEL below). - [missed-pattern] Dynamic-delegate "in-use reference counter" hand-rolls its own
as unknown as {…}cast in the two preset modules.evaluation-scales.service.ts(existsInUse/referenceCounts/computeInUseMap) andsubject-levels.service.ts(computeInUseMap) each punch through the delegate typing per call.getPersonDelegatealready exists to own exactly this single cast. Fix: add one thin accessor that owns theclient[model] as unknown as …cast; the three call sites keep their own methods (findFirst/count/groupBy). - [bad-architecture]
@Global()on FilesModule + PermissionsModule hides real dependencies.files/files.module.ts:23andpermissions/permissions.module.ts:16are global;auth.module.tsimports neither yetauth.service.tsinjects both. For a "module = future microservice" north star, globally-ambient domain providers are exactly the coupling a split must untangle. Fix: defer unless/until a service extraction is planned; then drop@Global()and import explicitly. - [overengineering]
permissions↔custom-fieldsmodule-levelforwardRefguards a cycle that does not exist.permissions.module.ts:18imports: [forwardRef(() => CustomFieldsModule)], butcustom-fields.module.tsdoes not import PermissionsModule (it pulls the guard via the@Global()PermissionsModule). The provider-level@Inject(forwardRef(...))is genuinely needed; the module-level one is not. Fix: replace with plainimports: [CustomFieldsModule](keep the provider-level forwardRef); verify by booting.
Domain / academic-year integrity (rollover-blockers, latent today)¶
- [domain] Curriculum-selection write + bulk-assign never call
assertYearWritable, and admin write bypasses the window gate.students/curriculum-selection.service.tswritegates referents on window-OPEN +referentCanWritebut letsisAdminthrough with no archive guard, resolving the student's storedacademicYearId(:153);bulk-curriculum-assignment.service.tslikewise omitsassertYearWritable. Harmless today (no API path produces an ARCHIVED year), but the stored-year writers are the paths that genuinely escape the guard once archiving ships.assertYearWritable's own docstring frames it as universal; homerooms/SGs/timetables also skip it but are moot (active-year-only). Fix (rollover prerequisite): addassertYearWritableto the curriculum-selection write + bulk-assign paths; document the active-year-only surfaces as intentionally exempt. - [potential-bug] Timetable publish gate is a TOCTOU.
timetables.service.ts:565-621— the PUBLISH branch runsdiagnose()→ single-PUBLISHEDfindFirstpre-check →updateTimetable(PUBLISHED)sequentially with no enclosing$transaction(contrastmutateLessonAndDiagnose, which wraps mutate+diagnose in one tx). A concurrent DRAFT edit can introduce an ERROR between diagnose and publish, shipping an invalid timetable. Admin-only + narrow window. Fix: wrap the publish branch in$transactionand re-runbuildDiagnosticsSnapshot+ conflict-check inside.
students¶
- [type-system]
loadDepartmentGradeCatalogforces atx as unknown as PrismaServicedouble-cast.students.service.ts:1491calls it with the cast; the helper (students.queries.ts:385) types its first paramprisma: PrismaServiceinstead ofPrismaService | Prisma.TransactionClient— the lonequeries.tshelper opting out of the client-union convention every sibling follows. Fix: widen the param to the union; delete the cast. - [missed-pattern]
getRolloverReviewPagehand-rolls pagination + filter assembly inline.students.service.ts:1297-1332— manual page/limit/skip clamping and an inlinePrisma.StudentWhereInputinstead of abuildListArgsFor…helper instudents.queries.ts. Fix: extract a purebuildListArgsForRolloverReviewhelper. - [verbosity] In-tx consistency-reject block duplicates the out-of-tx one.
students/curriculum-selection.service.ts:320-326(out-of-tx) and:374-388(in-tx locked replay) copy-paste the samefindSelectionInconsistencies+splitSelectionViolations+SELECTION_VALIDATION_FAILEDthrow. Fix: extract a localassertConsistent(structure, isAdmin)closure called from both passes.
curriculum¶
- [duplications] P2003 SubjectGroup-in-use disambiguation triplicated with divergent count semantics.
curriculum.service.ts:551-582(bulkSync catch) re-implements the disambiguation thatthrowIfSubjectGroupInUsealready does; the service paths count ALL SGs{ curriculumSubjectId }while the canonical guardguardAndClearStandaloneSGs(curriculum-structure-sync.ts:438) counts only SGs with students (assignments: { some: {} }) — so the displayedsubjectGroupCountcan contradict the "taught to students" message on a rare TOCTOU. Fix: have bulkSync's catch callthrowIfSubjectGroupInUse; align its count filter toassignments: { some: {} }. - [bad-practice]
CURRICULUM_IN_USE_BY_SELECTIONSin bulkSync emits emptycurriculumId/ fixedselectionCount: 1.curriculum.service.ts:581— the P2003 selections fallback passes{ curriculumId: '', selectionCount: 1 }, defeating the typed params the FE renders. Fix: thread the real id + a real count, or drop the placeholders. - [type-system]
expandPresetreturns an un-annotated shape that reaches the wire with no DTO tie.curriculum.service.ts:599— no declared return type; the{ name, subjects, optionBlocks }value is forwarded to the setup endpoint with no response DTO, so the contract is inference-only end-to-end. Fix: giveexpandPresetan explicit return interface and reference it from the endpoint's Swagger. - [verbosity]
ctx ? Policy.where(ctx) : { tenantId }visibility boilerplate copy-pasted ~10×. Acrosscurriculum.service.ts,selection-window.service.ts,curriculum.queries.ts:109(and mirrored inhomerooms.service.ts:1117/1142,subject-groups.service.ts:979/1002,referents:156). No shared helper. Fix: add a one-linevisibleWhere(policy, ctx, tenantId)helper. - [stale-doc]
assertAllCriteriaShapesJSDoc still claims a removed "contiguous1..n" rule.curriculum-structure-sync.ts:251— the JSDoc says unique names + contiguous1..nare enforced, butassertCriteriaShape(175-197) only checks unique names (ordinal was removed). Fix: drop the "+ contiguous1..n" clause.
timetables¶
- [potential-bug]
STUDENT_CONFLICTrenders the student UUID as itsdisplayName.timetables.diagnostics.ts:227—student: { id: sid, displayName: sid }; the snapshot carries onlystudentIds(no names), so the FE gets a raw UUID as the human label while every other violation category denormalizes a real name. Fix: batch student names into the snapshot (onestudent.findManymirroring the homeroom-roster batch) and use them; or leave a code comment marking it a v1 limitation. - [duplications]
checkSameDayContiguityandcheckSameRoomDoublerebuild an identicalbyGroupDayindex.timetables.diagnostics.ts:586,621— byte-for-byteMap<${subjectGroupId}|${weekday}, SnapshotLesson[]>loops. Fix: extract agroupByGroupDay(lessons)helper. - [bad-practice] Secondary snapshot/view queries omit a defensive
tenantId.timetables.queries.ts:357,453,538,548andviewWhereintimetables.service.ts:718filter bytimetableId/target id but nevertenantId. No leak today (transitively tenant-constrained), but the standing rule is every tenanted query carries an explicittenantId. Fix: addtenantIdto thesewheres and theviewWheretarget predicates. - [duplications]
GenerationService.toResponseduplicatesTimetablesService.toResponse.generation/generation.service.ts:267andtimetables.service.ts:49— identicalTimetableSummaryRow → TimetableResponseDtoprojection. Fix: export a freetoTimetableResponse(row)fromtimetables.queries.ts; call from both. - [code-smell]
TIMETABLE_SOLVER_TIME_LIMIT_SECONDSis read + parsed twice, unvalidated.generation/generation.service.ts:144(solver config) andgeneration/solver.port.ts:190(fetch abort budget) bothNumber(process.env… ?? '300')with noNumber.isFiniteguard — a malformed value yieldsNaN→AbortSignal.timeout(NaN). Fix: oneresolveSolverTimeLimitSeconds()that coerces + falls back onNaN/≤0, consumed by both. - [type-system] Two response-DTO sub-objects use inline/object types instead of DTO classes.
dto/timetable-response.dto.ts:29(subjectGroupinline object) and:47(effectiveWindowvia Swaggertype:'object'); the file already definesNamedRef/SlotGrid. Fix: promote toSubjectGroupRef+DateWindowDtoclasses. - [code-smell]
setStatuspublish-conflict backstop emitsblockingTimetableId: ''.timetables.service.ts:617— themapP2002backstop throwsTIMETABLE_PUBLISH_CONFLICTwith an empty-string id the contract types as a required id. Fix: re-query the blocking row's id in the catch, or make the param optional and omit it on the index-race branch.
timetable-templates¶
- [potential-bug] "Too many slots" overflow reuses
reason: 'EMPTY'.timetable-templates.service.ts:987-993— theslots.length > MAX_SLOTS_PER_DAY_TEMPLATEbranch throwsDAY_TEMPLATE_INVALID_SLOTSwithreason: 'EMPTY'(the semantic opposite), and the reason union has no over-max member. Fix: add a'TOO_MANY_SLOTS'member to the union inerror-codes.tsand emit it.
permissions¶
- [potential-bug] Parametric-overlap admission is unreachable on
@AppliesPolicy-only routes; JSDoc + docs imply otherwise.roles.guard.ts:21JSDoc claimsparameterDimensionsis "populated by the JWT payload at login," but it is populated only insideensurePermissionsLoaded(which ScopeGuard/ActionGuard trigger) — so on command-center dashboard routes (no scope/action guard) the overlap branch seesundefined → []and never fires. Bites nothing today only by coincidence (no@AppliesPolicy-only route uses a policy withparametricBranches);docs/04-rbac.md:1023invariant #4 implies it holds everywhere. Fix: correct the JSDoc (it is not a JWT claim) and narrow invariant #4 to "routes with a scope/action gate." - [code-smell]
system-managed-fieldshardcodes raw entity-key strings.permissions/constants/system-managed-fields.ts:20— outer record typedRecord<string, …>keyed by'students'/'teachers'/etc. rather thanPartial<Record<EntityKeyValue, …>>+EntityKey.*; a typo or rename silently makes the sub-field rule a no-op. Fix: type + key it with theEntityKeyconstants (compiler-checked). - [duplications]
buildEntityDtoandbuildRoleEntityPermissionsduplicate the scope/action assembly.permissions.service.ts:331and:461— identical NONE-filter + catalogue-actions→booleans construction. Fix: extract abuildScopeActionMaps(...)both call, then layer their differences.
teachers¶
- [code-smell]
TEACHER_EXTRA_REGISTRYkeyinvalid_deptis dead.teachers/constants/import-schema.ts:227— the validator emitsdepartments:invalidand the aggregator keys on the post-:suffix (invalid), soinvalid_deptnever matches. Fix: drop it (or rename keys to the resolved suffixes) and trim the comment. - [potential-bug]
assertTeachersInTenantmaps a bad teacher reference to 409 CONFLICT.teachers/assert-teachers-in-tenant.helper.ts:32— a malformed/forbidden reference should be 400/404, not 409. FE-facing status nit (TEACHER_NOT_IN_TENANTalready gives a stable code). Fix: switch toNOT_FOUND/BAD_REQUESTafter checking the cross-module call sites (homerooms/subject-groups) don't rely on 409.
setup¶
- [potential-bug]
ParseGroupIdPipeinoperator admits inherited prototype keys.setup/pipes/parse-group-id.pipe.ts:8—!(value in SETUP_GROUPS)letsconstructor/toString/etc. pass the "known group" gate. Tiny blast radius (routing hint only, response stays tenant-scoped). Fix:Object.prototype.hasOwnProperty.call(SETUP_GROUPS, value)or test the enum. - [type-system]
SetupCompleteStateDto.datauses Swaggertype: Object.setup/dto/setup-state-response.dto.ts:213— emits an untyped{}schema on a field whose TS type is literallynull. Fix: droptype: Object.
subject-groups¶
- [code-smell] Dead query function
findOptionBlockSelectorIds.subject-groups/subject-groups.queries.ts:295— exported, no non-test caller (in-block eligibility is decided inline byclassifySubjectGroupEligibility). Fix: delete it. - [missed-pattern] Create-path gate re-queries
OptionBlock.maxSelectionsthe caller already holds.subject-groups.service.ts:1190—assertStudentsEligibleissues its ownoptionBlock.findUniqueformaxSelectionseven oncreate, where the resolved anchor already carries it. Fix: giveassertStudentsEligiblean optionalmaxSelections?and pass it fromcreate; keep thefindUniquefallback for add/move. - [domain]
moveStudentschecks same-subject but not same-grade — API-direct cross-grade move possible.subject-groups.service.ts:915— two standalone SGs sharingcurriculumSubjectIdcan live in different grades, and move re-points with no eligibility re-check, producing a roster where the SG teaches grade H but holds a grade-G student. FE never does this; API doesn't enforce it. Fix: also requiresource.gradeId === target.gradeId(confirm same-subject/different-grade standalone SGs are a real config first).
command-center¶
- [duplications] Duplicated referent-caller predicate across service + queries.
completeness.queries.ts:177andcompleteness.service.ts:344both carry the self-narrow predicate (a comment cross-links them); one decides which rows return, the other which row getsisMe. Fix: extract one pureisSelfNarrowedReferent(ctx)inshared/. - [missed-pattern]
csvToArrayis a weaker reimplementation of@TransformToArray().command-center/shared/query-helpers.ts:17— no trim/empty-filter/undefined-on-empty, used via@Transformin three query DTOs. Fix: replace with@TransformToArray()and deletecsvToArray.
homerooms¶
- [potential-bug] Move/add-path P2002 on
(studentId, subjectGroupId)maps to the misleadingSTUDENT_ALREADY_IN_HOMEROOM.homerooms.service.ts:1326—mapWizardP2002Targettestscurriculum_subject_idthen/student_?id/i; the(studentId, subjectGroupId)unique lackscurriculum_subject_id, so its race falls through to the homeroom branch. Rare (broader constraint usually trips first). Fix: add a JSDoc note on the fall-through, or tighten the branch with a homeroom-context token only if it surfaces.
departments¶
- [missed-pattern]
removeForDepartmenthand-rolls delete + P2025 catch the base owns.grades.service.ts:241-253— reproduces the baseremove's P2025→NOT_FOUND mapping. Fix: route throughsuper.remove(id, tenantId)after the ownership/existence guard; keepvalidateDepartmentOwnershipas the 404-on-invisible-parent guard. - [code-smell] Policies carry unreachable
student/referent/staffpass-through branches.departments.policy.ts:33,41,42+grades.policy.ts:27,35,36— those roles are never granted the read scope, so the branches are dead and (being tenant-flat) would silently grant tenant-wide enumeration if ever granted. Fix: delete them or replace'pass-through'withNEVER_MATCH_WHERE. - [type-system]
HookContextis an untyped string-index bag.common/interfaces/crud.interfaces.ts:1-4—{ [key: string]: unknown };departments.service.ts:172,189reads/writesctx._studentPlatformAccessWasEnabledthrough it, so a typo silently disables the ON→OFF invitation cascade. Fix: declare a typedDepartmentsHookContextslot and type the hookctxparams. - [bad-practice] Detail
gradeslist documented with an inline Swagger object schema.dto/scopes/department-configuration.dto.ts:215-227— inlineitems:{ type:'object', properties:{…} }hand-duplicatingGradeListItemDto(same file, line 238). Fix:@ApiProperty({ type: [GradeListItemDto] }). - [duplications]
bulkSyncparam +getSetupSummaryreturn inline the department-with-periods shape.departments.service.ts:656-673and:910-927— the full nested object type spelled out twice instead of referencing the wizard'sDepartmentItemDto. Fix: reference a shared DTO/type. - [verbosity]
toDetailResponsere-implements base scope assembly by hand.departments.service.ts:574-642— manually rebuildsconfiguration(native fields +pickCustomFields+computeMissingFields+mergeDynamicScopes), so the field list is maintained in two places. Fix: build the base scoped response and augmentconfigurationwithgrades+calendar. - [stale-doc] Governing calendar plan describes a nullable inherit-from-AY model that was reworked before ship.
docs/superpowers/plans/2026-06-04-department-scoped-academic-calendar.md— describes nullablecalendarStartDate/EndDate(null = inherit AY),resolveEffective*,Period.departmentId NULLdefault set, and the removedPeriodType.EXTRA; shipped code uses mandatory per-department dates. Fix: add a "Superseded / as-shipped" banner (dates mandatory, no inherit, EXTRA removed) pointing atproject_department_scoped_calendar.
rooms¶
- [duplications]
timeToMinutesduplicated between validation and the DTO validator.rooms/rooms.validation.tsexportstimeToMinutes;rooms/dto/scopes/room-configuration.dto.ts:40-43defines an identical inlinetoMinutesclosure inEndTimeAfterStartTime. Fix: import the exported helper. - [verbosity]
bulkSync/syncRoomsinput shapes are large inline literals, duplicated.rooms.service.ts:458-471and:624-634— the same{ roomTypes:[…]; rooms:[…] }/ room-item shape spelled out twice. Fix: one namedRoomsBulkSyncInputtype referenced by both. - [stale-doc] Stale test comment references a removed "lazy seed."
rooms/rooms-import.spec.ts:11— "Standard types the lazy seed guarantees…"; standard types are now platform-global rows (tenantId IS NULL), no per-tenant lazy seed. Fix: update the comment.
auth¶
- [missed-pattern]
completeProfileSelectionre-implements the centralizedassertProfileAvailable.auth.service.ts:472-485— inlinesresolveAvailableProfiles+ the availability throw the helper (:1092) already centralizes. Fix: callassertProfileAvailable(..., 'idempotent')(or add a'select-profile'member mapping to 400). - [bad-practice]
JWT_EXPIRATIONdefault handling diverges.auth.module.ts:19-20usesconfigService.get('JWT_EXPIRATION') ?? '1h'whileauth.service.ts/cookie-helper.service.tsgetOrThrow— so the'1h'fallback is dead code that falsely reads as "missing var tolerated." Fix: usegetOrThrowin the module too. - [potential-bug]
accessTokenExpiresAtrecomputed fromDate.now()rather than the signed JWT.auth.service.ts:580-581— an independent re-derivation of the token's realexp; agrees today only because both read the same env var. Fix: return the decoded token'sexp. - [bad-practice]
LoginDto.emailis unbounded while password is length-capped.auth/dto/login.dto.ts:11—@IsEmail()with no@MaxLengthon an unauthenticated route (feedsuser.findMany+ argon2 dummy-verify). Fix: add@MaxLength(254). - [stale-doc] Selection-token TTL is 10m but DTO JSDoc still advertises 60s.
auth/dto/auth-response.dto.ts:121+auth/dto/profile-selection-response.dto.ts:17render "Short-lived JWT (60s)" verbatim into the public OpenAPI doc; the real TTL isSELECTION_TOKEN_TTL = '10m'(docs/03-auth.mdalready corrected). Fix: change both DTO comments to 10m (or drop the parenthetical).
referents¶
- [duplications]
deleteDocumentByIdissues the same row lookup twice.referents.service.ts:590— its ownfindFirst({ id, ...ReferentsPolicy.where(ctx) })+assertCallerIsSelfOrAdmin, thenpersonDocs.deleteByIdrunsrequireRowwith the identical WHERE again. Fix: runconfig.assertCanWriteinsidePersonDocumentService.deleteById(coordinate withfiles/) and drop the pre-check; or accept the duplication (admin/self-only, low traffic). - [verbosity] School-relationship recompute over-fires on link-metadata-only PATCH.
referents.service.ts:373— every successful PATCH triggers a full per-linked-student recompute, even when onlycanWrite/isAuthorizedPickup/contact fields changed (flags derive only fromtaxCode/email/relationshipType). Fail-soft + post-commit, so bounded. Fix: gate the recompute on a flag-relevant change. - [missed-pattern]
createhand-rolls field assembly instead of reusingflattenDto.referents.service.ts:211—pickDefined({ ...identity, ...optionalContacts, ...documents })duplicatingflattenDto, diverging only to splitemailout. Fix: callflattenDto(dto)thenomit(..., 'email'), or a tiny shared helper.
invitations¶
- [stale-doc]
StudentInvitationDtoomitted from the controllerextraModels.invitations/invitations.swagger.ts:32-38— lists the other three recipient DTOs but not STUDENT (still emitted transitively viaProjectedInvitationDtoDocs). Cosmetic drift + a latent footgun if that decorator is ever dropped. Fix: addStudentInvitationDtofor parity. - [verbosity]
tokenTtlDaysre-parses env on every access via a getter.invitations.service.ts:125-127—configService.get+Number.parseIntper recipient (up to 500×/batch) on static deployment config. Fix: resolve once in the constructor into areadonly tokenTtlMs.
files¶
- [duplications] 10 MB upload cap hardcoded across controllers instead of a shared constant.
files/files.service.ts:162has a non-exportedMAX_BYTES; Multerlimits.fileSizeis hand-written across the person controllers + rooms as10_485_760or10 * 1024 * 1024(two spellings). Fix: exportMAX_BYTES(alongside the already-exportedMAX_FILES_PER_UPLOAD) and reference it everywhere. - [type-system] Storage-error log hardcodes
bucket: 'storage'.files/files.service.ts:289— a dead literal (FilesServiceholds no bucket name), so an operator inspecting a failedputgets nothing useful;storageKeyalready carries the useful prefix. Fix: drop thebucket: 'storage'field.
evaluation-scales¶
- [verbosity]
existsInUse/referenceCounts/computeInUseMapeach re-walkSCALE_FK_REFERENCESwith a separate hand-written delegate cast.evaluation-scales.service.ts:562,597,646— three near-identicalPromise.all(map(...))loops, each with its ownas unknown as {…}punch-through. Fix: one privateprobeReferences(ids, op)that owns the cast once (see the cross-module delegate-cast item). - [code-smell]
totalReferenceshand-lists the six FK keys instead of summing the typed counts.evaluation-scales.service.ts:617— a seventh FK column would silently under-count. Fix:Object.values(counts).reduce((a,b)=>a+b, 0). - [stale-doc] Design spec references a stale pre-grid-remodel reference shape.
docs/superpowers/specs/2026-05-18-evaluation-scales-design.md:403—references: { curricula; studyPlans; planSubjects; blockSubjects }and cascade against removedStudyPlan/OptionBlockSubjectcolumns; live contract is six grid-era columns. Fix: add a one-line "Superseded" banner near §5/§7.
custom-fields¶
- [type-system] Controller handlers omit explicit return-type annotations.
custom-fields.controller.ts:54,71,89,111,138—create/findAll/findOne/update/removeinfer from the service, with no compile-time link to the advertisedCustomFieldResponseDto. Fix: annotate the handlers. - [duplications]
DefinitionWithScopetype duplicates the inlinetoResponseparam type.custom-fields.service.ts:759re-declaresCustomFieldDefinition & { scope: { key } }inline instead of the existing named type (already imported). Fix: annotatetoResponse(def: DefinitionWithScope). - [stale-doc]
custom-fields.serviceJSDoc listsreferentsandroom_typesas custom-field entity tables.custom-fields.service.ts:53-54— neither is in the real whitelistCUSTOM_FIELD_ENTITY_KEYS(which omits both and includeshomerooms/subject_groups). Benign (assertValidEntityTablerejects any non-whitelisted key) but misleading. Fix: replace the example list with the actual members.
academic-years¶
- [verbosity]
createre-fetches viagetByIdForTenant— a round-trip with a known-empty result.academic-years.service.ts:160— a new DRAFT year always has empty related lists, so the re-fetch is guaranteed{ departments: [], grades: [], curricula: [] }. Fix (create path only): build the detail DTO directly from thecreateresult; leave the update-path re-fetch.
school¶
- [missed-pattern]
@AppliesPolicy(SchoolPolicy)is wired butSchoolPolicy.whereis never ANDed in the service.school.controller.ts:41+school.service.ts(updatefilters by{ tenantId }only) — the policy's only live effect is@RequireRoles('admin'). Fix: for a tenant-singleton with an admin-only write, dropSchoolPolicyin favour of a plain@RequireRoles('admin'). - [domain] Logo "≤ 5 MiB raw" cap is advertised but never enforced on decoded bytes.
school.service.ts:22—decodePngLogochecks the PNG signature + a min length but neverbuffer.lengthagainst a max; only the ~7M-char base64 string is bounded. Fix: soften the copy, or add oneif (buffer.length > MAX_LOGO_BYTES) throw ….
subject-levels¶
- [type-system]
computeInUseMapcasts the Prisma delegate throughas unknown as { groupBy }.subject-levels.service.ts:292— only two FK refs (both keyedlevelId), so two direct typedgroupBycalls would type-check natively. Fix: replace the cast loop with direct calls (keepLEVEL_FK_REFERENCESonly for the delete-time counts if wanted). - [potential-bug] Name-uniqueness check is case-insensitive but the DB uniques are case-sensitive, and inserts aren't wrapped in
mapP2002.subject-levels.service.ts:148,200— a concurrent same-case insert surfaces as a rawP2002→ 500 instead of the typedSUBJECT_LEVEL_NAME_TAKEN409. Narrow window, low-write catalog. Fix: wrap create (+ update) inmapP2002.
mailer¶
- [code-smell] "Single-subscriber by design" comment contradicts the multi-handler fan-out.
mailer/mailer.service.ts:39— the field ishandlers[], the code loops all handlers, and the port doc says "fan out to all." Fix: drop the comment (or collapse to one handler field if single-subscriber really is the invariant). - [type-system] Redundant optional chain on the non-optional
ResendEvent.data.mailer/resend/resend-webhook.controller.ts:51—event.data?.email_idwheredatais declared non-optional (types.ts) and the mapper accesses it with no chain. Fix: drop the?.(body is verified-signed JSON parsed against the type).
infra (logger / health / config)¶
- [bad-practice] Logger gates pretty-vs-JSON on
NODE_ENV === 'production'only.logger/logger.module.ts:15— diverges from the canonicalisProductionLikeEnv(common/constants/environments.ts), sostage/developmentrunpino-pretty(a log-aggregation foot-gun). Fix: gate on!isProductionLikeEnv(...)so onlylocal/testget pretty output. - [type-system] Health handlers return untyped object literals;
HealthResponseDtois unused as a type.health/health.controller.ts:49,56,66— inline{ status, timestamp }with no return type, while the DTO exists but is referenced only by Swagger. Fix: annotate the three handlers: HealthResponseDto. - [stale-doc] 429 documented only on the
/healthalias, not/health/liveor/health/ready.health/health.swagger.ts:29— the globalThrottlerGuardrate-limits all three, but only the alias declaresApiTooManyRequestsResponse(and the platform probes hit/health/ready). Fix: add the same response toApiLivenessCheck+ApiReadinessCheck.
common (services / utils / framework)¶
- [domain]
getImportPreviewcount/items are tenant-wide, not academic-year-scoped.common/services/base-tenanted-crud.service.ts:867—where = { tenantId }with noacademicYearId, whilefindAllcomposes the year; once a tenant has a second year the import-summary headline count over-reports. Harmless during single-year setup. Fix (rollover): scope the preview to the active year; add a// TODO(rollover)marker now. - [potential-bug]
import-field-rulesmaxLengthmeasures untrimmedraw.length.common/utils/import-field-rules.ts:77— inconsistent with the trimmed empty-check and trimmed persistence, so a value withinmaxafter trimming but over with whitespace is wrongly rejected. Fix: measuretrimmed.length. - [stale-doc] Stale comment in
omit.tsreferences a cast that does not exist.common/utils/omit.ts:11— the comment describes "cast restores correct Omit shape" but line 12 is a barereturn result;. Fix: delete the comment. - [type-system]
ROLLOVER_INVALID_STUDENT_IDStyped inErrorParamsMapasparams, but thrown + documented underdata.error-codes.ts:317declares it as params; both throw sites (students.service.ts:1446,1484) pass it underdata(typedunknown, so no compile check), and the example/docs put it underdata. The type protects the field nobody fills. Fix: drop theErrorParamsMapentry + type the throws asAppException<RolloverInvalidData>, or move the payload toparams. Add a one-line error-chapter rule: a code is params-shaped or data-carrying, never params-declared-but-data-thrown. - [type-system] Empty-object
{}param types defeat theParamsForguard for two timetable-template codes.error-codes.ts:493,527—DAY_TEMPLATE_REQUIRES_PERIOD: {}andWEEK_TEMPLATE_REQUIRES_OPERATIVE_DAY: {}; in TS{}accepts any non-nullish value (the opposite of "no params"), whereas omission resolves tonever. Both throw sites pass no params. Fix: delete both entries (behavior-preserving). - [potential-bug]
ApiCreateEndpoint/ApiUpdateEndpointhardcode "Duplicate email within tenant" conflict copy for all entities.common/decorators/api-crud-helpers.ts:123,190— rendered verbatim into the public OpenAPI doc; departments/grades opt intoconflict: truefor name/ordinal conflicts. Fix: add an optionalconflictDescription?defaulting to a generic message; per-entity overrides where it matters.
schema / migrations (doc-hygiene)¶
- [stale-doc]
schema.prismacites "Prisma 5", a truncated migration name, and two different breadcrumbs for the active-year partial unique.schema.prisma:1295(and:1258) — the project is on Prisma 7, the real folder is20260525150000_active_year_unique_and_invitation_token_ttl, and a second JSDoc block cites20260429000000_…for the same invariant with a different index name. Fix: correct the reference, drop "Prisma 5", and reconcile the two breadcrumbs to the migration whose index name the code relies on. - [stale-doc]
diet_typeconstant-USING column-wipe pattern is undocumented indocs/12. The20260622120000_align_teacher_staff_diet_type_enummigration usesALTER COLUMN … TYPE … USING 'STANDARD'(hazard #4) — a sanctioned decision for disposable free-text values, but the precedent isn't captured, so the next such migration relitigates it. Fix: add a short "constant-USING column wipe" note todocs/12's worked-examples (the duplicate-slug / double-timestamp note is already present).
solver (solver/)¶
- [duplications] Registered
_UnauthorizedErrorhandler is dead on the generate path.solver/app/main.py:107-111—generate()wraps_verify_bearerin its own try/except rebuilding the identical 401 envelope inline, so the registered handler never fires. Fix: make_verify_beareraDependsand delete the inline try/except (or delete the unused handler) — one source. - [potential-bug] Unguarded
int(content-length)can yield a raw 500 that violates the error-shape contract.solver/app/main.py:115— a non-numericContent-LengthraisesValueErrorbefore the body-parse try, surfacing as Starlette's bare 500 with no{"error": …}envelope. Fix: drop the header pre-check and rely on the existing post-readlen(body) > _MAX_BODY_BYTESguard. - [type-system]
minimizationIterations/minimizationBudgetExhaustedare computed and typed but never forwarded. The solver populates them andgeneration.types.tsdeclares them, butgeneration.service.ts:165-167buildssolveMetadatawith onlywallClockMs+objectiveValueandSolveMetadataDto(dto/generation-result.dto.ts:21-31) exposes only those two — a contract field that implies data flows through when it doesn't. Fix: either drop the two fields or add them to the DTO + INFEASIBLE-branch data. - [bad-practice] Solver
INTERNAL_ERRORresponse echoes the raw exception string.solver/app/main.py:145—"message": str(exc)on the 500 path; the BE discards the body anyway andlogger.exceptionalready captures it server-side. Fix: return a generic message (theINVALID_INPUTstr(exc)is user-actionable and can stay).
docs¶
- [stale-doc] Signed-off / shipped specs still carry
status: Draftfrontmatter. ~15 specs match^status: Draft, several with a filled §12 Sign-off ("Approved by: Fabio Barbieri") on a landed, REFERENCE-documented feature; the design-gate procedure defines the field but no step advances it, so a landed spec is indistinguishable from a pending one. Fix: either drop the frontmatterstatus:field (the filled §12 is the real signal) or add a "flipstatus:to Approved at sign-off" step to the design-gate procedure. (Genuinely-pending specs — e.g. the combined-classes and Wave-4 domain-event drafts — stayDraft.)
Method note. Architecture findings distilled from the 2026-06-25 → 06-29 → 07-02 architecture-audit lineage (10 parallel investigators: 8 hypotheses + 2 open scouts, each instructed to disconfirm as well as confirm). Quality findings distilled from the 2026-06-22 quality audit (per-module + cross-module deep-reads, adversarially verified) and re-verified against the working tree on 2026-07-03 — items fixed since (e.g. the ParametricContext cast, staff DELETE role allowlist, files let row implicit-any, docs/03 TTL text, the evaluation-scales preset-name unique index, the migration double-timestamp note) were dropped. Test-coverage and architecture-judgment axes are out of scope for this pass and tracked in their own files. file:line anchors were accurate at re-verification time — verify before quoting.