Skoolsewa - School Management System Docs
Developer ResourcesPeople

People Features and Flows

Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for the people domain (students, guardians, staff).

People Features and Flows

The people domain is the school's roll: every pupil, every parent or other responsible adult, and everyone on the payroll. One record shape underlies all three — a users row carrying name, contact, demographics and address, plus one profile row (students, guardians, or staff) carrying whatever is specific to that hat. A person who is both a teacher and a parent is two profile rows pointing at the same users row, not two people.

1. Documentation Evidence

Source TypeFiles or DocsWhat Was Extracted
Schemapackages/db/src/schema/school/people.ts, packages/db/src/schema/identity.ts, packages/db/src/schema/school/lookups.tsTable shapes, constraints, indexes, the guardian/sibling model, the salary generated column.
BackendSibling backend doc, apps/api/src/modules/people/**/*.service.tsBusiness behavior, transactions, cache invalidation, code allocation, deletion rules.
APISibling API doc, apps/api/src/modules/people/**/*.controller.tsRoute surface, permissions, guards, request/response shape.
Authorizationpackages/db/src/authorization/permission-catalog.ts, packages/db/src/seed/seed-auth.tsWhich modules exist, which of the five seeded roles hold what by default.
Staff rolesapps/api/src/modules/people/staff/staff-roles.service.ts, staff.service.ts, staff.controller.ts, dto/staff.dto.ts, apps/api/src/modules/role/role.service.ts, apps/api/src/common/authorization/actor-authority.service.ts, apps/api/src/modules/realtime/shared/realtime-control.tsRole resolution, authorization checks, the picker routes, the edit choices, row-visibility refusal, stream closing.
Admin panelskoolsewa-frontend/app/(dashboard)/dashboard/parents/_components/, .../staff/_components/, .../teachers/The parent sign-in checkbox and toast, staff list invalidation, the create role select, the teacher routes.
Testsstudents.service.integration.spec.ts, guardians.service.spec.ts, staff.service.spec.tsConfirmed edge-case behavior — cited per row below.

2. Feature Summary

FieldValue
ModulePeople
SubmoduleStudents, Guardians, Staff (plus StudentMedical and StaffSalary as field-level sub-permissions)
Primary user valueAn admissions or HR office can record a pupil, a parent, or an employee once, correctly, and every other module in the school platform can trust that record without re-implementing who may see it.
ActorsSuperadmin (universal bypass), an administrator role holding the Students/Guardians/Staff permission set, staff and teacher (read-only directory access by default), guardian (their own children only), student (their own record only).
Main entry pointsPOST /students, POST /guardians, POST /staff, and the list/detail/update/delete/restore/account-action routes under each.
Main outputsA users row and a profile row persisted in one transaction; a StudentDto / GuardianDto / StaffDto response; role grants (guardian, staff, teacher) issued alongside the profile; a queued password-reset email.
Related docsAPI reference, Backend documentation.

3. Actor Matrix

Scope is decided by the session's active role, never by the union of every role a person holds. A teacher who is also a parent, viewing as Guardian, gets the guardian scope — switching context is what makes that mean anything. See PeopleAccessService.scopeFor in the backend doc.

ActorCan DoCannot DoAuth RequirementNotes
SuperadminEverything below, unconditionally.Nothing is withheld.JWT with activeRole.isSuperadmin = true.RoleGuard short-circuits on the flag, never on a role name — a role cannot be renamed into a bypass.
Administrator (a role holding the Students/Guardians/Staff/StaffSalary/StudentMedical permission codes)Full CRUD, restore, ban/unban, password-reset link, on students, guardians, and staff alike. Salary and medical fields only with the matching _READ/_UPDATE code. Cannot ban, unban, or reset the credentials of an account holding the superadmin role unless the administrator is a superadmin too.Nothing structurally, but each field group is gated separately — holding Staff_READ alone does not unlock /staff/:id/salary.JWT, active role must hold the specific Module_ACTION code.None of the five seeded roles hold this by default (see the Business Process Diagram Pack below); this is a role an administrator creates through the Roles module.
Administrator also holding Users_UPDATEChoose the roles a new staff member is admitted under, and change the roles an existing staff member holds. Grant and revoke sign-in.Give a staff member a guardian- or pupil-scope role. Choose an explicit role for a teaching designation. Change their own roles. Change a superadmin's roles unless they are a superadmin. Add or remove a role carrying permissions they do not hold themselves. Remove the last superadmin role.JWT, active role holds Users_UPDATE together with Staff_CREATE (admission) or Staff_UPDATE (edit).Choosing roles is an identity change, so the staff permission alone never reaches it. Without Users_UPDATE the same admission or edit succeeds as long as it names no role.
Staff (seeded default) / Teacher (seeded default)Students_READ, Guardians_READ, Staff_READ — list and read the whole directory.Create, update, delete, restore, ban, salary, medical.JWT, active role staff or teacher.Seeded with read-only directory grants; see STAFF_PERMISSIONS in seed-auth.ts. A teacher holds every staff grant plus the screens for their own teaching work (TEACHER_PERMISSIONS spreads STAFF_PERMISSIONS and adds the assignment and class-limited report codes). A teacher is a staff row with a teaching designation, and holds both the staff and the teacher role because the designation implies them.
GuardianRead their own children (students), read their own guardian record and their children's other guardians, read their own users row.Read anyone outside that set — refused with the entity's own 404, never a 403. Create, update or delete anyone.JWT, active role guardian.Holds no module permission by default (GUARDIAN_PERMISSIONS = []); access to their own family runs entirely through object-level scope, not a permission code.
StudentRead their own students row and their own guardians.Read any other student or guardian. Anything else.JWT, active role student.Same as guardian: no module permission, scope only.
Anyone with no active role, or a role holding none of the aboveRead only their own staff/users row if one exists for them.Everything else.JWT, no usable active role.The safe default in PeopleAccessService.selfOnlyScope — an absent case defaults to sql\false``, never to "everything".

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
Admit a studentAdminAdministratorPOST /studentsGuardian rows (if linking existing), admission-number counterusers, students, student_guardianStudents: Create
List studentsAdminAdministrator, staff, teacher, guardian (own children), student (self)GET /studentsstudents joined to usersNone (cached read)Students: List
Look up guardians by phoneAdminAdministrator, staff, teacherPOST /students/guardian-lookupguardians joined to usersNoneStudents: Guardian lookup
Update a studentAdminAdministratorPATCH /students/:idCurrent students/users row, locked FOR UPDATEusers, studentsStudents: Update
Replace a student's guardiansAdminAdministratorPUT /students/:id/guardiansExisting links, candidate guardiansstudent_guardian (full replace)Students: Set guardians
Read a student's health recordAdminAdministrator with StudentMedical_READGET /students/:id/medicalstudents.medical_conditions/allergies/special_needsNoneStudents: Medical
Update a student's health recordAdminAdministrator with StudentMedical_UPDATEPATCH /students/:id/medicalSame three columnsSame three columnsStudents: Medical update
Soft delete / restore a studentAdminAdministratorDELETE /students/:id, POST /students/:id/restoreProfile-liveness count across all three profile tablesstudents.deleted_at, possibly users.deleted_at, account, sessionsStudents: Delete/Restore
Suspend / restore a student's loginAdminAdministratorPOST /students/:id/ban, .../unbanusers.bannedusers.banned, .ban_reason, .banned_at, .banned_by; deletes sessionsStudents: Ban
Email a student a password-reset linkAdminAdministratorPOST /students/:id/password-resetusers.email/can_login/bannedA verification-token rowStudents: Password reset
Give a student a portal accountAdminAdministrator holding Users_UPDATEPOST /students/:id/sign-in, or grantSignIn on POST /studentsusers.can_login/emailusers.can_login; an account_invite verification row and a queued invitation emailStudents: Grant sign-in
Take a student's portal account awayAdminAdministrator holding Users_UPDATEDELETE /students/:id/sign-inusers.can_loginusers.can_login; deletes sessionsStudents: Revoke sign-in
Create a guardianAdminAdministratorPOST /guardians—users, guardians, user_role (grants guardian)Guardians: Create
List / read a guardian, their childrenAdminAdministrator, guardian (self)GET /guardians, GET /guardians/:id, GET /guardians/:id/studentsguardians joined to users; student_guardian joined to studentsNoneGuardians: List/Read
Update / delete / restore / ban / unban / reset a guardianAdminAdministratorPATCH/DELETE/POST :id/restore/.../ban/.../unban/.../password-reset on /guardians/:idCurrent rowusers, guardiansGuardians: reference
Give a guardian a portal account, or take it awayAdminAdministrator holding Users_UPDATEPOST/DELETE /guardians/:id/sign-in, or grantSignIn on POST /guardians and on a guardian entry inside POST /studentsusers.can_login/emailusers.can_login; an account_invite verification row and a queued invitation on a grant, deleted sessions on a revokeGuardians: Grant sign-in
Admit a staff memberAdminAdministratorPOST /staffDepartment/designation FK targets, employee-code counterusers, staff, user_role (grants staff, and teacher for a teaching designation; or exactly the roles named in rolePublicIds for a non-teaching designation)Staff: Create
List / read / update / delete / restore a staff memberAdminAdministrator, staff/teacher (list/read only)/staff routesstaff joined to users, departments, designationsusers, staffStaff: reference
Read / update a staff member's salary and bank detailsAdminAdministrator with StaffSalary_READ/_UPDATEGET/PATCH /staff/:id/salarystaff salary columnsSame columnsStaff: Salary
Suspend / restore a staff member's loginAdminAdministratorPOST /staff/:id/ban, .../unbanusers.banned; the target's full role set (superadmin protection)users.banned, .ban_reason, .banned_at, .banned_by; deletes sessionsStaff: Ban
Email a staff member a password-reset linkAdminAdministratorPOST /staff/:id/password-resetusers.email/can_login/banned; the target's full role setA verification-token rowStaff: Password reset
Give a staff member a portal account, or take it awayAdminAdministrator holding Users_UPDATEPOST/DELETE /staff/:id/sign-in, or grantSignIn on POST /staffusers.can_login/email; the target's full role setusers.can_login; an account_invite verification row and a queued invitation on a grant, deleted sessions on a revokeStaff: Grant sign-in
List the roles that can be given to a staff memberAdminAdministrator holding Staff_CREATE and Users_UPDATE (new member), or Staff_UPDATE and Users_UPDATE (existing member)GET /staff/assignable-roles, GET /staff/:id/assignable-rolesThe actor's own permissions and the role tableNoneStaff: Assignable roles (new), Staff: Assignable roles (existing)
Change a staff member's rolesAdminAdministrator holding Staff_UPDATE and Users_UPDATEPATCH /staff/:id with roleSource (and rolePublicIds for explicit)The target's managed roles, designation, and the actor's own permissionsuser_role rows; the audit log; a request to close the person's open live-update streamsStaff: Update
See a staff member's rolesAdminAdministrator holding Staff_UPDATEGET /staff/:id, and the response of POST /staff and PATCH /staff/:iduser_role joined to roleNoneStaff: Read
Limit a role to guardians or pupilsAdminAdministrator holding Roles_UPDATEPUT /roles/:publicId/row-visibility (RoleService.setRowVisibility)user_role joined to staff for the rolerole.scope_kind—
Read headline people countsAdminAdministrator, staff, teacher (any role with Students_READ)GET /people/summarystudents, student_class_enrollments, academic_sessions; staff and designations with Staff_READ; guardians with Guardians_READNone (60-second cached read)People summary

5. User-Facing Flows

5.1 Admit a student

Summary

The admissions desk records a new pupil: identity, admission details, and at least one guardian — either reusing an existing parent (the sibling case) or creating one inline.

Preconditions

  • Actor authenticated, active role holding Students_CREATE.
  • If guardians are linked by guardianId, those guardian rows must already exist and be live.
  • If a person.email is supplied for the student or an inline guardian, it must not already belong to a live person.

Main Flow

StepActor/SystemActionResultSource
1Admissions officerSubmits POST /students with person, admissionDate, and optionally guardians[].DTO validated.students.controller.ts
2StudentsService.createValidates the guardian set — at least one entry, exactly one primary, no repeated slot.Rejects with STUDENT_REQUIRES_ONE_GUARDIAN, STUDENT_GUARDIAN_PRIMARY_REQUIRED / _MULTIPLE_PRIMARY, or GUARDIAN_SLOT_TAKEN if invalid.student-guardians.service.ts
3BackendOpens a transaction: inserts users, allocates or accepts an admission number, inserts students, writes guardian links.All four steps commit together or none does.students.service.ts
4BackendReloads the full StudentDto (person + guardian count).Response returned.students.service.ts
5BackendInvalidates every cached student list.Next list read is a miss.students.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
No guardians givenguardians omitted or [].Refused before the transaction opens — a pupil must have at least one guardian.400 STUDENT_REQUIRES_ONE_GUARDIAN.
Sibling attachguardians[].guardianId names an existing live guardian.Links to that row; no new users/guardians insert.Same guardian appears on both students' guardian lists.
Explicit admission numberadmissionNumber supplied.Used as-is; must still satisfy students_admission_number_unique.STUDENT_ADMISSION_NUMBER_TAKEN on collision.
Explicit student IDstudentId supplied (school migrating an existing roll).Canonicalised, format- and range-checked, and the year's counter advanced so a later auto-allocation cannot collide with it.400 STUDENT_ID_MALFORMED, 400 STUDENT_ID_SEQUENCE_OUT_OF_RANGE, or 409 STUDENT_ID_TAKEN on collision (checked against every row, including removed ones).
Duplicate guardian in the setTwo entries resolve to the same guardian id.Rejected before any insert.409 STUDENT_GUARDIAN_ALREADY_LINKED.
Two entries in the same slotTwo entries both name relationship: "father" (or any other slot).Rejected before any insert, naming the slot.400 GUARDIAN_SLOT_TAKEN.
Email already heldperson.email (student's or an inline guardian's) belongs to a live person.Whole transaction aborted.409 USER_EMAIL_ALREADY_EXISTS.
Address half-filledA district given without its province, or similar, on either permanentAddress or currentAddress.Rejected before any insert.409 ADDRESS_HIERARCHY_INVALID.
No sign-in access asked forNeither grantSignIn nor person.canLogin sent.The pupil gets a record and no portal account. This is the ordinary case.201, with invitation: null.
Sign-in asked for, actor lacks Users_UPDATEgrantSignIn: true (or person.canLogin: true) from an actor holding only the create permission.Refused before the transaction opens — an account is an identity change, not a record edit.403 AUTH_FORBIDDEN.
The two sign-in flags disagreegrantSignIn and person.canLogin both sent with different values.Refused rather than resolved by precedence.400 PERSON_SIGN_IN_FLAGS_CONFLICT.
Sign-in granted, no email addressgrantSignIn: true on somebody with no person.email.The account is created; there is simply nowhere to send the invitation.201, with invitation: {"sent": false, "reason": "no_email"}.
A guardian in the set is granted sign-inguardians[].grantSignIn: true on an entry creating a new guardian.Needs Users_UPDATE as well as Guardians_CREATE; the guardian is invited in the same transaction.403 AUTH_FORBIDDEN without the permission.

5.2 Attach a sibling to an existing guardian

Summary

The admissions desk searches by phone number before typing a name, so the second child of a family shares the first child's guardian row rather than creating a duplicate parent.

Preconditions

  • Actor holds Guardians_READ (for the lookup) and Students_CREATE or Students_UPDATE/Guardians_UPDATE (to attach).

Main Flow

StepActor/SystemActionResultSource
1OfficerPOST /students/guardian-lookup with the family's phone number.Every live guardian on that number returned — a household sharing one number is normal, so this can be a list.student-guardians.service.ts
2OfficerPicks the matching guardian and admits the second child with guardians: [{ guardianId, relationship, ... }], or attaches via PUT /students/:id/guardians.Second child links to the SAME guardians.id.student-guardians.service.ts
3BackendresolveGuardian finds the row by id rather than creating one.No duplicate users/guardians rows for that parent.student-guardians.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
No match on phoneNo live guardian shares that number.Empty array returned.Office creates a new guardian inline.
Several matchesA household number shared by more than one adult.All returned; office must choose.List, not a single pick — there is deliberately no "the" match.
Guardian id no longer liveThe chosen guardianId was soft-deleted between lookup and submit.Rejected.404 GUARDIAN_NOT_FOUND.
Blank or missing phone numberThe operator submits the lookup with no phone typed.Rejected before the query runs, rather than matching nothing.400 VALIDATION_FAILED.

5.3 Update a student, guardian, or staff member

Summary

An office edits identity or profile fields on an existing record. Students carry optimistic concurrency; guardians and staff do not.

Preconditions

  • Actor holds the entity's _UPDATE permission and passes PeopleAccessService.assertCanAccess for that specific row.
  • For a student or a staff member, the request must carry the version token last read from the record.

Main Flow

StepActor/SystemActionResultSource
1EditorPATCH /students/:id (or /guardians/:id, /staff/:id) with only the changed fields.Partial patch.Each *.controller.ts
2BackendLocks the current row FOR UPDATE (students, staff) or plain read (guardians).Row visibility confirmed.Each *.service.ts
3Backend (students, staff and guardians)Compares dto.version against versionOf(current.version), under the row lock.Mismatch aborts with no write.students.service.ts, staff.service.ts, staff-salary.service.ts
4BackendBuilds a patch containing only the keys the caller actually sent (PersonWriterService.buildUpdate), so an omitted field is left untouched rather than nulled.users and the profile table updated.person-writer.service.ts
5BackendInvalidates the entity's cached lists.Next read is fresh.Each *.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Stale versiondto.version does not match the locked row's version counter.Write refused, nothing changes.409 PEOPLE_STALE_RECORD.
Field omitted from the bodyKey absent from person or the top-level DTO.Left exactly as it was.Distinguished from null, which explicitly clears it.
Email changed to one already live elsewhereNew email collides with another live person.Whole update rejected.409 USER_EMAIL_ALREADY_EXISTS.
Guardian's kind changes to organizationorganizationName recomputed from person.firstName on every write that could touch either half, not just when kind itself is sent.Stays coherent with the CHECK constraint.No client-visible error on the happy path.
Staff designation moved to a different departmentdesignationId no longer matches departmentId.Rejected at the database.409 STAFF_DESIGNATION_NOT_IN_DEPARTMENT.
Concurrent guardian PATCHTwo editors write the same guardian at once.The second is refused before it writes.409 PEOPLE_STALE_RECORD.
Concurrent staff PATCHTwo editors write the same staff member at once.The second is refused before it writes.409 PEOPLE_STALE_RECORD.

5.4 Replace a student's guardian set

Summary

The office corrects the whole family picture at once — who is primary, who may collect the child, who is the emergency contact — rather than adding and removing links one at a time.

Preconditions

  • Actor holds Students_UPDATE and Guardians_UPDATE.
  • If any guardians are given, exactly one must be marked primary.

Main Flow

StepActor/SystemActionResultSource
1EditorPUT /students/:id/guardians with the full desired set.Validated as a whole.student-guardians.service.ts
2BackendLocks the student row, deletes every existing student_guardian row for it.Clean slate inside the transaction.student-guardians.service.ts
3BackendInserts every entry with is_primary = false first.Avoids the non-deferrable unique index racing against rows about to be deleted.student-guardians.service.ts
4BackendSets is_primary = true on the one designated primary, as a second statement.Exactly one primary, or none if the set is empty.student-guardians.service.ts
5BackendInvalidates cached student lists (guardian count and completeness changed).student-guardians.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Empty setguardians: [].All links removed; record becomes incomplete.200 with an empty array.
Zero primaries with entriesAt least one entry, none marked primary.Rejected before any write.400 STUDENT_GUARDIAN_PRIMARY_REQUIRED.
Two primariesMore than one entry marked primary.Rejected before any write.400 STUDENT_GUARDIAN_MULTIPLE_PRIMARY.
Reassigning primary between two existing guardiansOld primary and new primary both already linked.Both re-inserted as non-primary, then the new one flipped — never a moment with two primaries in the same statement.Succeeds; the non-deferrable index is never hit mid-transaction.

5.5 Read a student's health record or a staff member's salary

Summary

Two fields groups are held out of the base record and gated by their own permission, so a teacher reading the roll never incidentally reads a diagnosis, and a colleague with plain Staff_READ never incidentally reads a payslip.

Preconditions

  • Actor passes PeopleAccessService.assertCanAccess for the base entity, and holds StudentMedical_READ/_UPDATE or StaffSalary_READ/_UPDATE respectively.

Main Flow

StepActor/SystemActionResultSource
1Nurse/HRGET /students/:id/medical or GET /staff/:id/salary.Field-gated payload only.student-medical.service.ts, staff-salary.service.ts
2BackendChecks object-level scope for the base entity, then checks the specific field permission with PeoplePermissionsService.can.Two independent gates.Same files
3BackendReads only the gated columns — never the whole profile row.Response never carries Students_READ/Staff_READ fields either.Same files

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Holds Staff_READ but not StaffSalary_READColleague-level access only.RoleGuard already refuses the route (permission declared on the handler); PeoplePermissionsService.can is the second, defense-in-depth check inside the service.403 PERMISSION_INSUFFICIENT.
Salary never setbasicSalary/allowances both NULL.totalSalary is NULL, never 0.00.Response shows all three as null.
Salary update breaks the pairPATCH sets basicSalary but leaves a NULL allowances (or vice versa).Rejected before the write.400 VALIDATION_FAILED.
Out-of-scope student's medical recordRequested student exists but is not this actor's.Same 404 as a missing student — never a 403.404 STUDENT_NOT_FOUND.

5.6 Suspend and restore a person's sign-in (students, guardians, staff)

Summary

An office can stop somebody signing in without touching their record — a fee-suspended pupil stays on the roll and countable, a suspended parent is still the emergency contact on file, and a staff member under investigation stays employed on paper while locked out of a session.

Preconditions

  • Actor holds the entity's _UPDATE permission.
  • The target must not be the actor themselves.
  • If the target holds the superadmin role, the actor must hold it too — an ordinary administrator cannot suspend, restore, or reset a superadmin's credentials no matter which entity's _UPDATE permission they hold.

Main Flow

StepActor/SystemActionResultSource
1OfficerPOST /students/:id/ban (or /guardians/:id/ban, /staff/:id/ban) with a reason.Validated non-blank.people-account.service.ts
2BackendRefuses self-targeting, refuses a non-superadmin acting on a superadmin, and refuses to demote the last live superadmin.people-account.service.ts, actor-authority.service.ts
3BackendSets users.banned = true plus reason/timestamp/actor, in a transaction.people-account.service.ts
4BackendDeletes every session the person holds.Any refresh token becomes unusable immediately, not just at next banned check.people-account.service.ts
5OfficerPOST /students/:id/unban later.banned cleared and banReason/bannedAt/bannedBy cleared — a stale reason must not linger on a restored account. Still refuses a non-superadmin restoring a superadmin's account.people-account.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Blank reasonreason empty or whitespace.Rejected.400 USER_BAN_REASON_REQUIRED.
Self-targetingActor bans their own users.id.Rejected — the UI that would undo it just refused the actor.409 USER_CANNOT_DELETE_SELF.
Target is a superadmin, actor is notChecked against the target's full role set, not their currently active role — a superadmin acting as a guardian in this session is still protected.Rejected before the last-superadmin count ever runs, closing the path where a clerk suspends a superadmin while a second one remains on file.403 USER_SUPERADMIN_PROTECTED.
Last superadminTarget holds the superadmin role and no other live, password-holding superadmin exists.Rejected.403 USER_LAST_SUPERADMIN_PROTECTED.
Already bannedBan called twice.Idempotent — overwrites reason/timestamp/actor.200, no error.

Summary

An administrator triggers a reset link, never a new password — the office never learns a child's or colleague's credential.

Preconditions

  • Actor holds the entity's _UPDATE permission.
  • If the target holds the superadmin role, the actor must hold it too.
  • Target has an email address, canLogin = true, and is not banned.

Main Flow

StepActor/SystemActionResultSource
1OfficerPOST /students/:id/password-reset (or /guardians/:id/..., /staff/:id/...).people-account.service.ts
2BackendRefuses a non-superadmin acting on a superadmin.actor-authority.service.ts
3BackendLoads the person, validates email present, canLogin, not banned.people-account.service.ts
4BackendCreates a single-use verification token via VerificationTokenService.createPasswordReset.people-account.service.ts
5BackendSends the email via AuthEmailService.sendPasswordResetEmailSafe (fail-soft — a delivery failure does not fail the request).people-account.service.ts
6BackendReturns { sentTo } so the operator can confirm the address before telling the family.people-account.service.ts

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Target is a superadmin, actor is notA reset link is a route to somebody's credentials — a clerk who could trigger one on a superadmin's account could invalidate their current password at will.Rejected before the email/login/ban state is even loaded.403 USER_SUPERADMIN_PROTECTED.
No email on fileusers.email IS NULL.Rejected — nowhere to send it.409 USER_EMAIL_REQUIRED.
canLogin = falsePerson cannot sign in at all.Rejected — a working link to a disabled account is a support call.409 USER_LOGIN_DISABLED.
Banned accountusers.banned = true.Rejected — restore the account first.409 AUTH_ACCOUNT_BANNED.
Email delivery failsSMTP/provider error.Request still succeeds — this path is deliberately fail-soft.200 {sentTo}, delivery logged separately.

5.8 Give somebody a portal account, or take it away (students, guardians, staff)

Summary

An administrator turns sign-in access on for a person who has a record but no login, and the person receives an email inviting them to choose a password. The same screen turns it off again, ending every session they hold.

Preconditions

  • The actor holds Users_UPDATE — not the profile-kind permission. Granting sign-in creates a credential-bearing account rather than editing a pupil, parent or employee record.
  • The target is a live person within the actor's scope.
  • The target does not hold the superadmin role unless the actor does too.

Main Flow

StepActorActionScreen/RouteSource
1AdministratorSwitches the sign-in toggle on from the detail screen.POST /:entity/:id/sign-inThe three controllers
2BackendLoads the person and their current sign-in state.people-account.service.ts
3BackendWrites can_login = true, guarded by the value it just read, unless it already holds it.people-account.service.ts
4BackendUnless the operator asked not to invite, mints a 7-day invitation and queues the email — on the same transaction as step 3, so the two commit together or not at all.people-invitation.service.ts
5AdministratorSees whether the invitation went, and to which address.SignInAccessDto.invitation
6The personOpens the link, chooses a password, and can sign in.POST /api/auth/password/resetauth-password.service.ts

Revoking is the same route with DELETE, no body: can_login goes to false under the same guard, and every session the person holds is deleted.

Branches and Edge Cases

BranchConditionBehaviorError/Result
Granting to somebody who already has accessThe toggle is switched on twice, or an operator returns after adding a missing email address.The state change is skipped and the invitation is decided afresh — so the second call is what actually sends it.200, with the invitation outcome of this call.
Revoking from somebody who has noneThe toggle is switched off twice.Nothing at all: no write, no session sweep. A revoke is the one direction with no second question to answer.200, canLogin: false, reason: "revoked".
Somebody else changed it firstcan_login moved between this request's read and its write.The guarded write matches no row and is reported rather than applied over the other change.409 PERSON_SIGN_IN_STATE_CHANGED — re-read and try again.
No email addressSign-in granted to somebody with no address on file.The grant stands; only the invitation is skipped. Add an address and grant again to send it.200, with reason: "no_email".
Invitation deliberately withheldinvite: false.The account is prepared silently, for an operator who will tell the person another way.200, with reason: "not_requested".
Target is a superadmin, actor is notThe same protection ban, unban and reset-link all apply.Refused before anything is read about their sign-in state.403 USER_SUPERADMIN_PROTECTED.
Target is suspendedusers.banned = true.The grant stands — the two flags are independent — but no invitation is sent, because an invitation into an account the login guard will refuse produces a support call rather than an activated account. Lift the suspension and grant again.200, with reason: "banned".
The invitation cannot be scheduledA database or outbox failure while queueing the email.The grant rolls back with it. Reporting somebody as invited when nothing was scheduled would leave them holding credentials they will never be told about.The request fails; sign-in access is unchanged.
Invitation not opened in timeMore than 7 days pass.The link stops working.400 AUTH_VERIFICATION_CODE_INVALID — grant again, or send a reset link, to issue a fresh one.
The person also holds a reset linkAn administrator sent a reset link as well as an invitation.Whichever they use first retires the other, so a password set through one cannot be undone by the other still working.The unused link then answers AUTH_VERIFICATION_CODE_INVALID.

An invitation is redeemable by link only. The one-time code that a password-reset email carries is never printed in an invitation, because the code lookup matches on purpose and person with nothing to tell two live records apart — a correct code checked against the wrong record would fail and burn one of the five attempts that retire it.

Revoking is not the same as suspending. A suspension records a reason and is a statement about conduct; revoking sign-in simply says the person no longer has a portal account. Either way their record is untouched, and so is every notification addressed to them.

5.9 Move or end a graduate's portal access

Summary

A rollover graduation opens a read-only window on the pupil portal: the graduate can sign in and read their own record, but nothing else, until the window's last day. An administrator reads that window, moves its last day earlier or later, or ends it immediately; the pupil portal itself refuses any write the graduate attempts once the window is over.

Preconditions

  • The actor holds Students_READ to read the window, Students_UPDATE to change it.
  • Changing the window additionally requires Users_UPDATE, because ending or re-opening access can switch the graduate's sign-in on or off — an identity change no Students_UPDATE holder is otherwise trusted with.
  • The target pupil is within the actor's object-level scope, checked before the graduation is even looked up, so an out-of-scope actor gets the same 404 an absent graduation produces.

Main Flow

StepActorActionScreen/RouteSource
1AdministratorOpens a pupil's graduate access panel.GET /api/students/:id/graduate-accessstudent-graduate-access.controller.ts
2BackendReads the pupil's live (unrevoked) graduation, if any, and computes none / pending / grace / ended against today's school-calendar date.graduate-access.util.ts
3AdministratorMoves the last day of access, or ends it now, with a reason.PATCH /api/students/:id/graduate-accessstudent-graduate-access.controller.ts
4BackendLocks the graduation row, applies the change inside one transaction, and writes an audit entry once it commits.graduate-access.service.ts

Branches and Edge Cases

BranchConditionBehaviorError/Result
Ending accessaction: "end_now".Refused while the graduation is still ahead of today — a pupil still attending is never cut off this way. Otherwise the pupil's own scope_kind = 'student' role grants are removed unconditionally, so a grant is never left behind for someone who later gets a login for another reason. Sign-in and every live session are removed too, but only when the graduate holds no other live profile — a graduate who is also a parent keeps their login for their children.200, with state: "ended", or 409 GRADUATE_ACCESS_NOT_STARTED if graduation has not begun.
Moving the last day into the future past an ended windowaction: "set_until" with a date >= today on an already-ended graduation.Access re-opens: sign-in is restored only when the actor holds Users_UPDATE (the route already requires it) and the account has not since gained other access; role grants come back only for roles that still exist, are still pupil-scoped and are not superadmin — a role widened or deleted since revocation is not silently handed back.200, with state recomputed from the new date.
Moving the last day into the past on a live windowaction: "set_until" with a date < today.The window is shortened and access ends immediately, exactly as end_now would.200, with state: "ended".
Date outside the graduation's allowed rangeThe requested date precedes the graduation date or is more than 3,650 days after it.Refused before any write.400 GRADUATE_ACCESS_DATE_INVALID.
No live graduationThe pupil has never graduated, or their graduation was revoked.GET reports state: "none"; PATCH is refused.404 GRADUATE_ACCESS_NOT_FOUND.
Actor lacks Users_UPDATEAny PATCH, regardless of the requested action.Refused before the graduation is even loaded, so a caller missing the permission learns that regardless of whether this pupil has graduated.403 GRADUATE_ACCESS_SIGN_IN_PERMISSION_REQUIRED.
A graduate attempts a portal writeThe pupil is in grace or ended state and tries to submit or request something through their own portal.Refused at the point the portal resolves the pupil's own identity, never on a raw id — so a stranger cannot learn a pupil has graduated from the shape of the refusal.403 STUDENT_GRADUATED_READ_ONLY.
A guardian reads their graduated childThe child's access has ended (grace is still visible; ended is not).The child stops resolving as this guardian's child at all — the same 404 a stranger's child produces, because ending access is an audited lifecycle transition, not a record toggle a guardian's own view should see through.404 STUDENT_NOT_FOUND.
Sign-in changed by hand afterwardAn administrator explicitly grants or revokes sign-in through the ordinary sign-in toggle, on either side of a graduation.That explicit decision clears the internal flag that remembers graduate access switched sign-in off, so a later re-opening of the graduation window never overrides it.No error — the two controls stop interacting.
A graduate's own read of their accessThe pupil reads their own window from the portal rather than an administrator reading it.Same four states, read through their own audience-scoped route rather than the admin one.200.

5.11 Edit a staff member's salary and bank details from the staff form

The nine writable pay fields — basic salary, allowances, bank name, account number, branch, PAN, citizenship number, SSF and CIT — can be entered when a staff member is created, from the detail page's own panel, and on the staff edit form.

Because a teacher is a staff row with a teaching designation and the teachers screen reuses the same form, this covers teachers too; there is no separate teacher edit form.

The form uses the two salary endpoints: GET /staff/:id/salary behind StaffSalary_READ and PATCH /staff/:id/salary behind StaffSalary_UPDATE. It issues the same two calls the detail panel does.

Three rules govern the block, and each one is load-bearing.

  • The block renders only when the pay was actually read. Not when the request merely completed — the client fetcher returns {status: "forbidden"} for a 403 rather than throwing, so "the query succeeded" is true of a refusal. Rendering on that would show nine blank editable fields to somebody who may not read them, who could then type a real account number, save, and be told it saved while the write was correctly refused.
  • The write is gated on the same fact, not on the permission alone. PATCH /staff/:id/salary is a full-record write: every blank field is sent as an explicit null, because omitting a cleared field would leave the old value in place. Without a loaded baseline, a save would therefore replace bank, PAN, citizenship, SSF and CIT with nulls — so an actor holding StaffSalary_UPDATE and not StaffSalary_READ issues no salary request at all.
  • The pay is sent only when it changed, compared field by field with both sides normalised: the record stores null where the form holds "", so a naive comparison reports every untouched load as changed and rewrites the whole record on every unrelated save.

Two requests, not one. The staff patch runs first and the pay second, and only if the first succeeded. PATCH /staff/:id accepts no salary block, so the key is stripped from that payload — the API runs forbidNonWhitelisted and would otherwise reject the whole save. If the pay leg fails after the staff patch landed, the message says so rather than implying the whole edit was lost.

Two further refusals apply to the write. Nobody edits their own pay or bank account, superadmin included (403 SALARY_SELF_CHANGE_FORBIDDEN). Once the salary module holds any assignment for the member, basicSalary and allowances change only through a salary assignment: sending a different figure is refused with 409 STAFF_SALARY_MANAGED_BY_SALARY_MODULE, while re-sending the unchanged figures beside a bank edit succeeds. The read reports this as managedBySalaryModule.

A staff member with no pay on file returns 200 with every column null, not a 404, so the block renders seeded blank — that is the population this change exists to serve.

Optimistic concurrency covers these fields. StaffSalaryDto carries the staff row's version counter, and PATCH /staff/:id/salary requires it: a second operator saving pay against a token that has moved is refused with 409 PEOPLE_STALE_RECORD rather than overwriting the first silently. The check runs inside a transaction with the row locked, so it cannot be raced.

The token is shared with the general staff edit, because both routes write the same staff row. A client that saves the employment record and then the pay must send the version the first call returned. What the token does NOT cover is the person's users row — a concurrent rename through ban, unban, password reset or the users module does not move it.

5.12 Read the headline people counts

The dashboard shows active enrolled pupils, pupils admitted this Bikram Sambat month, employed and teaching staff, and guardians.

  1. The client calls GET /api/people/summary.
  2. The API requires Students_READ; a role without it receives 403.
  3. The students section counts pupils within the caller's own scope. wholeSchool is true when that scope is the whole roll and false when the caller sees only part of it.
  4. The staff section appears only for a role holding Staff_READ, and the guardians section only for a role holding Guardians_READ. Otherwise the section is null and the client hides the tile.
  5. activeEnrolled counts pupils with an active record and an active enrolment in the current academic session; it is null when no session is current.
  6. month carries the Bikram Sambat month and its Gregorian first and last day, which bound admittedThisMonth.

Counts are cached for 60 seconds. A student, staff, guardian or enrolment write clears them immediately; a bulk import, the graduate-access sweep or a change to a designation's teaching flag appears within 60 seconds. A slow or unavailable Redis never delays the response: the counts are read from the database.

5.13 Choose a staff member's roles

Summary

A staff member's roles decide what they can see and do once signed in. By default the job decides: every staff member holds staff, and a teaching designation adds teacher. An administrator can instead choose the roles at admission, and can change them later. Staff never hold a guardian- or pupil-scope role; a person who is also a parent or a pupil keeps that grant through their other profile, and the staff surfaces never show or touch it.

Preconditions

  • Choosing roles on admission needs Users_UPDATE as well as Staff_CREATE. Changing roles on an existing member needs Users_UPDATE as well as Staff_UPDATE. Choosing somebody's authority is an identity change, the same line sign-in access is drawn at. An admission or edit that names no role needs only the staff permission.
  • Every role named must be one the actor could hand out: a role whose permissions the actor holds in full, and the superadmin role only if the actor is a superadmin.
  • Changing roles on an existing member additionally requires the target to be somebody other than the actor.

Main Flow

StepActor/SystemActionResultSource
1AdministratorOpens the admission form; the Role select loads GET /staff/assignable-roles.The roles the actor may hand out, minus guardian- and pupil-scope roles, in name order.staff.controller.ts, staff-roles.service.ts (assignableRoles)
2AdministratorLeaves the select on "From the job (Staff)", or picks one role.Unpicked, the job decides. Picked, the chosen role replaces the job's roles: staff is not added implicitly.StaffForm.tsx, useCreateRolePicker.ts
3Backend (admission)Checks Users_UPDATE before the transaction opens, then inside it refuses a teaching designation, resolves each id, and checks the actor may hand out each role.The roles are granted in the admission transaction, or nothing is created.staff.service.ts (create), staff-roles.service.ts (resolveForCreate)
4AdministratorChanges an existing member's roles from the edit form's Role select, which sends PATCH /staff/:id carrying roleSource.See the three choices below.staff.controller.ts, StaffEditRoleField.tsx, useEditRolePicker.ts, staffEditRole.ts
5Backend (edit)Checks the pairing and Users_UPDATE, then the target (not self, not a protected superadmin), then the new set, inside the update transaction.A refusal rolls the staff patch back with it.staff.service.ts (changeRoles), staff-roles.service.ts (changeRoles)
6Backend (edit)Diffs the new set against the roles staff surfaces manage, checks the actor may hand out every role added and every role removed, then inserts and deletes.Only the difference is written.staff-roles.service.ts (replaceStaffRoles)
7Backend (edit)After commit, records an audit entry listing the roles added and removed, and, if any role was removed, asks the live-update service to close the person's open streams.The person reconnects with the permissions they hold now.staff.service.ts (afterRoleChange), realtime-control.ts

The edit endpoint takes one of three choices:

roleSourceEffect
Omitted (keep current)Roles are not touched. A designation change on its own never re-derives them.
jobThe roles become those the designation after this edit implies: staff, plus teacher for a teaching designation.
explicitThe roles become exactly rolePublicIds (one to twenty ids, staff not implied). rolePublicIds is sent with explicit and only with it.

Only the roles staff surfaces manage are replaced. A guardian- or pupil-scope role the person holds through another profile is never read, shown, added or removed by this flow.

The admin panel offers the role choice on the admission form and on the edit form. The edit form shows a Role select only to an actor holding both Staff_UPDATE and Users_UPDATE; any other viewer sees a read-only "Role: ..." line (or nothing, when the response carries no roles). The same read-only line replaces the select on the operator's own record, decided by comparing the signed-in user's id with the staff record's linked account id; while the session is unknown the select is still offered and the API refuses a self change. The select maps to the PATCH /staff/:id fields (Staff: Update); the roles come from GET /staff/:id/assignable-roles (Staff: Assignable roles (existing)).

Select choiceRequest fieldsNotes
Keep current (the default, labelled with the roles held now)None. The role keys are absent.An edit that changes only the designation, or any other field, never sends a role field.
From the job (Staff, plus Teacher for a teaching designation); "Use the teaching roles (Staff, Teacher)" on a teaching designationroleSource: "job"Resolved against the designation as saved by this edit.
One role from the pickerroleSource: "explicit", rolePublicIds: [id]The panel sends exactly one id. Not offered on a teaching designation.
  • On a teaching designation the select offers only Keep current and the job choice, because the API refuses an explicit role there (400 STAFF_ROLE_TEACHER_JOB_ONLY). Switching the form to a teaching designation puts an explicit pick back on Keep current.
  • While the assignable list is loading or has failed, the select offers only Keep current and the job choice.
  • Under the select, a "Replaces: ..." line names the roles the member holds now that the save would take away. It is empty for Keep current.
  • Refusals show these messages: PEOPLE_SELF_ROLE_CHANGE_FORBIDDEN shows "You can't change your own role."; PEOPLE_ROLE_NOT_PERMITTED, USER_SUPERADMIN_PROTECTED and a 403 ROLE_NOT_FOUND show "You can't change this person's role."; USER_LAST_SUPERADMIN_PROTECTED shows "This is the last superadmin; the role can't be removed."; STAFF_ROLE_SCOPE_NOT_ASSIGNABLE and STAFF_ROLE_TEACHER_JOB_ONLY show their own explanations; STAFF_ROLE_SOURCE_INVALID asks the operator to reload.

Only a viewer holding Staff_UPDATE sees a member's roles: the roles list (public id and name, in name order) is present on the detail response and on the create and update responses for that viewer, and absent otherwise. A colleague reading the directory learns nothing about who holds what.

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Actor lacks Users_UPDATErolePublicIds on admission, or roleSource / rolePublicIds on an edit.Refused before anything is written.403 PEOPLE_ROLE_NOT_PERMITTED.
Unpaired edit fieldsexplicit without rolePublicIds, or rolePublicIds with job.Refused.400 STAFF_ROLE_SOURCE_INVALID.
Own recordThe target is the actor. Refused even when the new set is a subset of what the actor holds, because a peer role is a way to climb.Refused.403 PEOPLE_SELF_ROLE_CHANGE_FORBIDDEN.
Target is a superadmin, actor is notChecked against the target's full role set.Refused.403 USER_SUPERADMIN_PROTECTED.
Teaching designationAn explicit role on admission, or explicit on an edit whose resulting designation is teaching.Refused: a teacher's roles come from the job.400 STAFF_ROLE_TEACHER_JOB_ONLY.
Unknown roleAn id names no role, including one deleted between the request and the grant.Refused; the roles are read FOR SHARE so a concurrent delete waits.400 ROLE_NOT_FOUND.
Guardian- or pupil-scope roleA chosen role is limited to guardians or pupils.Refused on admission and edit alike, and absent from both pickers.400 STAFF_ROLE_SCOPE_NOT_ASSIGNABLE.
Role beyond the actorA role added or removed carries permissions the actor does not hold, or is the superadmin role and the actor is not a superadmin. Removing a role is checked like adding one, so emptying a peer's roles is not a way to act above one's own level.Refused.403 PEOPLE_ROLE_NOT_PERMITTED.
Last superadminThe edit removes the superadmin role and no other live, unsuspended superadmin who can sign in and holds a password exists.Refused under a row lock, so two concurrent demotions cannot reach zero.403 USER_LAST_SUPERADMIN_PROTECTED.
job and a seeded role is missingstaff or teacher was deleted or never seeded.An edit refuses rather than grant fewer roles than the job implies. (An admission or import logs the fault and carries on.)500 ROLE_NOT_CONFIGURED.
A role is removedThe person has open live-update streams and sessions acting as that role.Streams are closed and reconnect with the new permissions; a session acting as the removed role is asked to choose a role on its next request. If the close request cannot be published, a warning is logged and the person keeps the permissions they connected with until they reconnect.200; the write is never failed by the close.
The role is later limited to guardians or pupilsPUT /roles/:publicId/row-visibility with scopeKind of guardian or student while any staff row, live or removed, holds the role.Refused, so a staff role cannot become a guardian or pupil one after the fact. A removed member counts because a restore brings the grant back. Remove the role from them first.409 ROLE_SCOPE_HELD_BY_STAFF.

5.14 Create a parent with sign-in access from the admin panel

The parent creation screen (/dashboard/parents/new) can create the parent and invite them in one submit.

  • The checkbox. An operator holding Users_UPDATE sees a "Grant sign-in access" checkbox. It renders only after the page has hydrated, because the permission is read from a client query and an unhydrated render would disagree with the server, and only on create. Editing sign-in after the parent exists happens on the parent detail page's sign-in card, not in the edit form.
  • What is sent. The panel sends grantSignIn: true only when the box is ticked and the operator still holds Users_UPDATE at the moment of submit. A tab left open across a role change therefore cannot post a grant the operator can no longer see. An unticked box sends no grantSignIn field at all, and the panel never sends person.canLogin, so the two flags cannot disagree (400 PERSON_SIGN_IN_FLAGS_CONFLICT).
  • What the backend does. POST /guardians accepts grantSignIn, refuses it with 403 AUTH_FORBIDDEN unless the actor holds Users_UPDATE, and writes the person, the guardian role grant and, when sign-in is granted and the parent has an email address, the invitation, all in one transaction. A parent with no email address is created with sign-in access and no invitation (invitation: {"sent": false, "reason": "no_email"}). The form shows a hint under the ticked box while the email field is empty.
  • The toast. The success message reports the invitation outcome:
Response invitationToast
null (no grant requested)"Parent created."
sent: true with an address"Parent created. Invitation sent to address."
sent: true without one"Parent created. Invitation sent."
sent: false, reason: "no_email"A warning: parent created with sign-in access, but no invitation was sent because there is no email address.
sent: false, any other reasonA warning: parent created with sign-in access, but no invitation was sent.

Sources: parents/_components/ParentForm.tsx, ParentSignInAccessField.tsx, useParentSignInGrant.ts, parent-form-utils.ts (buildCreateParentPayload), parent-form-announce.ts, and guardians.service.ts (create).

5.15 Staff and teacher lists after a write

The Staff and Teachers lists share one client-side query cache. After any successful staff create, update, suspend, delete, restore, unban, or sign-in grant or revoke, the panel invalidates every cached page and filter of that list, mounted or not, before it navigates or returns control. The row the operator just changed therefore shows its new state, including employment status, on the next view of either list, rather than the pre-write row for the rest of the cache lifetime. A refused write invalidates nothing. A password-reset link changes nothing the list shows and does not invalidate.

Only the list entries are invalidated. A member's salary and manager reads and the lookup reads are separate cache entries that a person write does not change.

Sources: staff/_components/staffListQuery.ts (invalidateStaffList), staffMutations.ts, StaffDetailsClient.tsx, StaffListClient.tsx, StaffTable.tsx.

5.16 Teacher routes

A teacher is a staff row with a teaching designation, so the Teachers screens reuse the staff pages rather than duplicate them.

RouteRendersBehavior
/dashboard/teachers/newThe staff create form, restricted to teaching designationsThe role select never renders, because a teacher's roles come from the designation. The save lands on the teacher's record under Teachers.
/dashboard/teachers/[id]The staff detail pageA record whose designation is not a teaching one redirects to /dashboard/staff/[id].
/dashboard/teachers/[id]/editThe staff edit formA non-teaching record redirects to /dashboard/staff/[id]/edit.

Links, redirects after a save and row actions for a record opened under Teachers stay under /dashboard/teachers, so the sidebar keeps highlighting Teachers. The staff routes never redirect back, which keeps the pair from looping.

Sources: teachers/**, staff/_components/staff-paths.ts.

6. Admin Flows

Every write endpoint under /students, /guardians, and /staff is an admin-surface flow — there is no separate mobile or public surface for this domain. The flows below are the ones the format's checklist calls out that are not already covered as user-facing flows above.

6.1 Create

Covered as 5.1 (students), and identically shaped for guardians (POST /guardians — one transaction: users, guardians, and a guardian role grant) and staff (POST /staff — one transaction: users, staff, and staff/teacher role grants).

Whether the person gets a portal account is decided before the transaction opens, from grantSignIn or the older person.canLogin, and requires Users_UPDATE on top of the create permission. Absent means no: most people on a school's roll want a record and no login. Roles and sign-in access are separate — the role grant above says what somebody may do once signed in, not whether they may sign in at all.

On POST /staff the role grant is the job's roles (staff, plus teacher for a teaching designation) unless rolePublicIds is sent. Sending it requires Users_UPDATE on top of Staff_CREATE, is refused for a teaching designation, and replaces the job's roles rather than adding to them. Flow 5.13 covers the rules.

6.2 List

Covered as the read path inside 5.1-5.7's supporting detail. Every list is scoped, cached, paginated (mandatory for guardians and staff; optional but supported for students), and searchable via trigram-plus-substring matching.

6.3 Read detail

Covered — findOne on each service, gated by PeopleAccessService.assertCanAccess.

6.4 Update

Covered as 5.3.

6.5 Reorder

Not applicable. Nothing in this domain has a manual display order.

6.6 Activate/deactivate

Nearest equivalent is employmentStatus on staff (active/on_leave/suspended/resigned/terminated/retired) and recordStatus on students (active/inactive) — both plain enum fields updated through the ordinary PATCH flow (5.3), not a dedicated action route. Login suspension (ban/unban, 5.6) is a separate axis from either.

6.7 Soft delete

6.8 Restore

6.9 Export/import

Not part of this module. Bulk admission/roll import is a separate DataImport/DataExport permission module and is out of scope here.

6.10 Moderation

Not applicable — there is no user-generated content in this domain to moderate.

6.11 Manual retry

Not applicable — every write here is synchronous; there is no queued job with a retry button.

7. Lifecycle and State Transitions

Three independent state axes exist per person, and they are deliberately never conflated:

  1. Profile liveness (deleted_at on students/guardians/staff, and on users once every profile is gone) — soft delete and restore.
  2. Login ability — two separate flags, and conflating them is a mistake the UI must not repeat. users.can_login says whether the person has a portal account at all; users.banned says whether an existing account is currently suspended, and carries a recorded reason. Both are independent of whether the record is live, and neither has any effect on notification delivery: somebody with no portal account still receives every message the school addresses to them.
  3. Domain status — students.record_status (active/inactive) and staff.employment_status (active/on_leave/suspended/resigned/terminated/retired) — a plain descriptive field the office sets, with no enforced transition graph of its own.
EntityFromEvent/ActionToGuard ConditionSide Effects
Profile (any)liveDELETE /:entity/:idsoft-deletedNot deleting the actor's own row; guardian has no live linked studentusers.deleted_at set only if no other live profile remains; account row and sessions deleted with it
Profile (any)soft-deletedPOST /:entity/:id/restoreliveReissuable code/email not claimed by a live row sinceusers.deleted_at cleared if it was set by this profile going
LoginactivePOST /:entity/:id/banbannedReason non-blank; not self; not a superadmin unless the actor is one too; not the last live superadminAll sessions for the user deleted
LoginbannedPOST /:entity/:id/unbanactiveNot a superadmin unless the actor is one tooReason/timestamp/actor cleared
Sign-in accessnone (can_login = false)POST /:entity/:id/sign-ingrantedActor holds Users_UPDATE; not a superadmin unless the actor is one too; can_login still holds the value just readUnless invite: false, the person has no email, or the account is suspended: a 7-day account_invite record and a queued invitation email, committed with the grant
Sign-in accessgrantedDELETE /:entity/:id/sign-innoneSame guardsAll sessions for the user deleted
Sign-in accessnonePOST /:entity with grantSignIn (or person.canLogin)grantedActor holds Users_UPDATEThe invitation is enqueued in the admission transaction, so it commits with the person or not at all
Staff rolesthe roles the member holdsPATCH /staff/:id with roleSource: "job" or "explicit"the job's roles, or exactly rolePublicIdsActor holds Users_UPDATE; target is not the actor and not a superadmin unless the actor is one; no explicit roles for a teaching designation; no guardian- or pupil-scope role; the actor holds every permission of each role added or removed; the last superadmin role staysAudit entry with the roles added and removed; open live-update streams closed when a role was removed
Staff rolesanyPATCH /staff/:id without roleSource, including a designation changeunchangedNoneNone
students.record_statusanyPATCH with recordStatusany other valueNone enforced — any value may follow any otherNone beyond the write itself
staff.employment_statusanyPATCH with employmentStatusany other valueNone enforcedNone beyond the write itself

Diagram (profile liveness, the only axis with real guard logic):

Login and domain-status are flat, unguarded toggles and are shown here as a single combined diagram for clarity rather than two trivial two-node graphs:

9. Data and Side Effects by Flow

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
Admit a studentusers, students, code_counters, student_guardian, possibly a second users/guardians for a new parentstudents:* sweptNoneNoneNoneNone
Admit a staff memberusers, staff, code_counters, user_role (the job's roles, or exactly the chosen roles)staff:* sweptNoneNoneNoneNone
Change a staff member's rolesstaff (version bump), user_role (added and removed rows); a session's active_role_id is nulled by the foreign key when its role is removedstaff:* sweptNoneA close-user message on the realtime control channel when a role was removed, so the person's open streams close and reconnectAudit entry (Staff / UPDATE) with rolesAdded and rolesRemoved, when anything changedNone
Create a guardianusers, guardians, user_roleguardians:* sweptNoneNoneNoneNone
Update any profileusers (identity patch), the profile tableThe entity's *:* list cache sweptNoneNoneNoneNone
Admit a studentusers, students, student_guardian, code_counters, user_role (the pupil's student role)students:* sweptNoneNoneNoneRefuses the whole admission with ROLE_NOT_FOUND if the student role is unseeded
Set student guardiansstudent_guardian (full delete + reinsert), students.updated_atstudents:* sweptNoneNoneNoneNone
Update medical recordstudents (three columns)students:* swept (list projects updatedAt)NoneNoneNoneNone
Update salarystaff (salary/bank columns)staff:* sweptNoneNoneNoneNone
Soft delete a profileThe profile table, conditionally users, account (deleted), sessions (deleted)Entity's *:* sweptNoneNoneNoneNone
Restore a profileThe profile table, conditionally usersEntity's *:* sweptNoneNoneNoneNone
Ban / unbanusers (ban fields), sessions (deleted on ban)Entity's *:* sweptNoneNoneNoneNone
Password-reset linkA verification-token row (owned by the auth module)NoneNoneNoneNonePassword-reset email, sent fail-soft

10. Error and Recovery Flows

ScenarioTriggerUser/System ExperienceRecoverySource
Stale PATCHTwo editors load the same student, both submit.Second submission gets 409 PEOPLE_STALE_RECORD.Reload, reapply the intended edit, resubmit.students.service.ts
Guardian still has childrenDELETE /guardians/:id on an active parent.409 GUARDIAN_HAS_LINKED_STUDENTS.Unlink the children first (PUT /students/:id/guardians on each), then delete.people-deletion.service.ts
Restore collides with a reissued codeAdmission number/employee code was handed to somebody else while the record was deleted.409 STUDENT_RESTORE_ADMISSION_NUMBER_CONFLICT / STAFF_RESTORE_EMPLOYEE_CODE_CONFLICT, naming the number/code.Assign the restored record a fresh number/code (update it), then restore succeeds.people-deletion.service.ts
Restore collides with a reissued emailA live account has since taken the deleted person's address.409 USER_RESTORE_EMAIL_CONFLICT.Change the email on the live holder or on the record being restored, then retry.people-deletion.service.ts
A clerk tries to ban, unban, or reset a superadmin's credentialsActor holds the entity's _UPDATE permission but does not hold the superadmin role, and the target does.403 USER_SUPERADMIN_PROTECTED.Have another superadmin perform the action.actor-authority.service.ts
Roles chosen without Users_UPDATErolePublicIds on POST /staff, or roleSource / rolePublicIds on PATCH /staff/:id, from an actor holding only the staff permission.403 PEOPLE_ROLE_NOT_PERMITTED; nothing is written.Have an administrator holding Users_UPDATE make the change, or resend without the role fields.staff.service.ts
Role choice refused by the target or the roleOwn record (403 PEOPLE_SELF_ROLE_CHANGE_FORBIDDEN), a superadmin's record (403 USER_SUPERADMIN_PROTECTED), a teaching designation (400 STAFF_ROLE_TEACHER_JOB_ONLY), a guardian- or pupil-scope role (400 STAFF_ROLE_SCOPE_NOT_ASSIGNABLE), an unknown role (400 ROLE_NOT_FOUND), the last superadmin (403 USER_LAST_SUPERADMIN_PROTECTED), or a role beyond the actor's own permissions (403 PEOPLE_ROLE_NOT_PERMITTED).The whole admission or edit is rolled back, including any staff field changes sent with it.Pick a role from the picker, which already omits guardian- and pupil-scope roles and roles the actor cannot hand out; have another administrator or superadmin act on a protected account; for a teacher, change the designation instead.staff-roles.service.ts
A role cannot be limited to guardians or pupilsPUT /roles/:publicId/row-visibility to guardian or student while any staff row holds the role.409 ROLE_SCOPE_HELD_BY_STAFF.Remove the role from the staff members who hold it, then retry.role.service.ts (setRowVisibility)
Live-update streams not closed after a role removalThe control channel is unavailable.The edit still succeeds; a WARN is logged. The person keeps the permissions they connected with until their stream reconnects.Automatic on reconnect; no operator action.realtime-control.ts (closeUserStreams)
Roll/directory list requested unpaginated?pagination=false on /students, /guardians, or /staff.400 PAGINATION_LIMIT_INVALID.Page through the results instead.students.service.ts, guardians.service.ts, staff.service.ts
Redis unavailable during a readCache layer down.Falls through to Postgres — slower, never a 500.Automatic; no operator action.redis.service.ts (getSoft)
Redis unavailable during invalidationCache layer down after a write.Write still succeeds; a WARN is logged; stale list entries persist until their 120-second TTL.Automatic expiry; no operator action needed for correctness (only for freshness).redis.service.ts (delPatternSoft)
Unmapped constraint violationA database CHECK/unique fires that PersonWriterService.translate does not recognise.The raw driver error is rethrown — surfaces as an unmapped 500.Add the constraint name to PersonWriterService.translate.person-writer.service.ts

11. Diagrams Required Per Module

  • Actor capability diagram — §3 above.
  • High-level module flow diagram — §6.1's create flowchart, representative of every write.
  • Sequence diagram for each major user/admin/system flow — §5.1 through §5.7.
  • State machine diagram for every lifecycle — §7.
  • Data side-effect diagram for write flows — §9 and §12.6.
  • Error branch diagram for critical flows — §6.7 (delete), §6.8 (restore).

12. Mandatory Feature and Flow Deep-Dive Pack

12.1 Feature Inventory With Minor Behaviors

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
Grant sign-inGranting to somebody who already has accessAdministratorPOST /:entity/:id/sign-inThe invitation is sent, and the state is left as it standsThe can_login write is skipped; the invitation is decided on its own, which is what makes "add an email and try again" workpeople-account.service.ts
Grant sign-inGranting to a suspended personAdministratorTarget has banned = trueThe account is granted; no invitation goes outReported as reason: "banned" — an invitation into an account the login guard refuses is a support callpeople-account.service.ts
Grant sign-inGranting without an invitationAdministratorinvite: falseThe account is prepared silentlycan_login written; no verification record, no emailsign-in-access.dto.ts
Grant sign-inGranting to somebody with no emailAdministratorPerson has no users.emailThe grant stands and the response says why nothing was sentcan_login written; invitation skipped with reason: "no_email"people-account.service.ts
Revoke sign-inRevoking from somebody who never had itAdministratorDELETE /:entity/:id/sign-inReported as success with the state unchangedNo write and no session sweep — the one direction allowed to return earlypeople-account.service.ts
Revoke sign-inAn unredeemed invitation is outstandingAdministratorRevoke after invitingThe link becomes useless without being deletedThe record expires on its own within 7 days; a password set through it would not permit sign-in anywaypeople-account.service.ts
Account invitationThe person also holds a password-reset linkThe invited personRedeeming either oneThe other stops workingconsumeAccountEntry retires every other account-entry record for that personverification-token.service.ts
Staff rolesAdmission without a role choiceAdministratorPOST /staff without rolePublicIdsThe member holds staff, plus teacher for a teaching designationgrantStaffRoles derives the job's roles; a missing seeded role is logged and the admission carries onstaff-roles.service.ts
Staff rolesAdmission with a role choiceAdministrator holding Users_UPDATEPOST /staff with rolePublicIdsThe member holds exactly the chosen roles; staff is not impliedResolved FOR SHARE, authorized per role, granted in the admission transactionstaff-roles.service.ts
Staff rolesPicker contentsAdministratorGET /staff/assignable-roles, GET /staff/:id/assignable-rolesOnly roles the actor may hand out, without guardian- and pupil-scope rolesActorAuthorityService.assignableRoles filtered by isStaffAssignableScope; the per-member route also runs the object-level access check (404 outside scope)staff-roles.service.ts, staff.service.ts
Staff rolesEdit without role fieldsAdministratorPATCH /staff/:id with a new designation and no roleSourceRoles stay as they wereNo role read or writestaff.service.ts
Staff rolesEdit with roleSource: "job"Administrator holding Users_UPDATEPATCH /staff/:idRoles re-derived from the designation the edit leaves in placeDiff against the managed roles; only the difference is writtenstaff-roles.service.ts (replaceStaffRoles)
Staff rolesGuardian or pupil grant held through another profileAdministratorAny role edit on a teacher who is also a parentThe guardian grant is untouched and not shownmanagedRoles excludes guardian- and pupil-scope roles from both the diff and the roles liststaff-roles.service.ts
Staff rolesRole removedAdministratorAny edit that removes a roleThe person's open live-update streams close and reconnectcloseUserStreams publishes close-user; failure is a WARNstaff.service.ts (afterRoleChange)
List studentsEmpty search termAny list callersearch=""Trimmed to nothing by QueryDto, treated as no searchNo trigram threshold pinned; no %/ILIKE condition addedcommon/dto/query.dto.ts, students.service.ts
List students% typed in a search boxAny list callersearch=100%The literal percent sign is matched, not treated as a wildcardescapeLikePattern escapes %, _, \ before the ILIKE halfperson-writer.service.ts
List studentsSearch with fewer than 3 extractable trigramsAny list callerVery short or non-Latin termTrigram half returns almost nothing; substring half still matchesBoth halves of personTrigramMatch/admission-number ILIKE run and are OR'dsearch.constants.ts, person-writer.service.ts
List (any)Tied updated_at across a bulk-imported batchAny list caller, page 2+Two rows share an identical timestampNo row is silently dropped or duplicated across pagesid is appended as a tie-breaker to every ORDER BYstudents.service.ts, guardians.service.ts, staff.service.ts
List studentshasGuardians=falseAny list callerFilter for incomplete recordsOnly students with no live guardian link returnednot(liveGuardianExists()) — a correlated NOT EXISTSstudents.service.ts
List guardianshasChildren=falseAny list callerFilter for guardians linked to nobody liveOnly guardians with no live linked student returnednot(liveChildExists())guardians.service.ts
List (any)includeDeleted=trueAny list callerWants to see removed records tooBoth the profile row and the users row must be checked — a users row can go dark independent of the profileBoth isNull filters lifted together, never just onestudents.service.ts comment on this exact bug
Sortsort/sortBy names a column not on the allow-listAny list callere.g. ?sort=basicSalary on staffRefused rather than silently defaulting or leaking a ranking400 PEOPLE_INVALID_SORT_FIELDstaff.service.ts, students.service.ts, guardians.service.ts
Students listpagination=falseAny list callerAttempt to fetch the whole student roll unpagedRefused — every pupil's name, date of birth, address and guardians in one response400 PAGINATION_LIMIT_INVALIDstudents.service.ts
Guardians listpagination=falseAny list callerAttempt to fetch the whole guardian directory unpagedRefused — a restricted scope's predicate is a per-row subquery400 PAGINATION_LIMIT_INVALIDguardians.service.ts
Staff listpagination=falseAny list callerAttempt to fetch the whole staff directory unpagedRefused — an unbounded read of every employment record400 PAGINATION_LIMIT_INVALIDstaff.service.ts
Create studentAdmission number omittedOfficerNo admissionNumber in the bodyOne is allocated atomically for the current academic yearcode_counters upsert in the school's own timezonepeople-code.service.ts
Create studentAdmission number suppliedOfficerImporting an existing rollUsed verbatim, still uniqueness-checkedstudents_admission_number_unique (partial, live rows only)students.service.ts, people.ts schema
Create guardiankind: "organization"OfficerAn NGO or company is the responsible partyorganizationName derived from person.firstName, never entered twiceguardian_org_has_name CHECK enforced at the database tooguardians.service.ts
Update guardianperson.firstName changes while kind stays organizationOfficerRenaming an org guardianorganizationName recomputed even though kind was not in the patchnewFirstName always recomputed from the merged current+patch stateguardians.service.ts
Update studentimeisId sent as an empty stringOfficerClearing a field via a blank form inputStored as NULL, not ""blankToNull / `.trim()
Delete guardianGuardian has a live linked studentOfficerDELETE /guardians/:idRefused, naming the reason409 GUARDIAN_HAS_LINKED_STUDENTSpeople-deletion.service.ts
Delete profilePerson holds another live profileOfficere.g. deleting the guardian profile of someone who is also staffusers row untouched — the person still exists in the systemhasNoLiveProfile counts across all three tablespeople-deletion.service.ts
Restore profileRestoring a no-opOfficerPOST .../restore on an already-live rowSilent no-op, 200if (row.deletedAt === null) return;people-deletion.service.ts
BanAlready bannedOfficerBan called twiceIdempotent overwrite of reason/timestamp/actorPlain UPDATE, no uniqueness or state-machine guardpeople-account.service.ts
Salary updateOnly bankName sent, no salary figuresHRBank-detail-only editbasicSalary/allowances pair check is skipped entirelytouchesBasic/touchesAllowances both falsestaff-salary.service.ts
Search cacheTwo searches differing only by field-permissionTwo different actorsOne holds StaffSalary_READ, one doesn'tEach gets its own cached list even though the filters are identicalviewTag folded into every cache keypeople-access.service.ts

12.2 Business Process Diagram Pack

DiagramRequired WhenPurpose
User journey mapAlways§5.1-§5.7 sequence diagrams.
Service blueprintMulti-actor or backend-heavy flowsBelow.
Activity diagramEvery major flow§6.1, §6.7, §6.8.
State diagramAny lifecycle§7.
Swimlane diagramMulti-actor flowBelow.
Sequence diagramAPI-backed flow§5.1-§5.7.
Data side-effect graphAny mutation§12.6.
Exception flow diagramCritical failure scenarios§6.7/§6.8, §10.

Service blueprint — admission:

Swimlane — who owns each part of an admission:

12.3 Business Rules and Policy Traceability

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
No father/mother columns; a student has zero or more guardians of any relationshipA mandatory "Father's Name" field forces a widow or a child of unknown parentage to account for an empty box at every admission.Every admission form asks for guardians as a list, never fixed slots.student_guardian join table, guardianRelationshipEnumguardians[] array on create/updateNo father/mother column exists anywhere to migrate away from laterstudents.service.integration.spec.ts — "records a student with no guardian"
Two students on the same guardian row are siblings; nothing else neededHalf-siblings sharing one parent, and full siblings, need no special case.Office links by guardianId, never re-enters the parent.student_guardian (many-to-many)POST /students/guardian-lookup finds the existing rowresolveGuardian links rather than creates when guardianId is givenstudents.service.integration.spec.ts — "attaches a sibling to the SAME guardian row"
Relationship is never defaulted in any UIA prefilled "father" becomes wrong data every time an operator tabs past it.Officer must actively choose a relationship.@IsEnum(GUARDIAN_RELATIONSHIPS), no @ApiPropertyOptional({default:...}) on relationshiprelationship is required on UpsertStudentGuardianDto—Schema comment, people.ts
isRecordComplete is computed, never storedA stored flag is a second writer that can disagree with reality; it must also account for a guardian's own soft delete.Office sees an accurate "incomplete" flag without a background job keeping it in sync.liveGuardianCount() correlated subquery joins student_guardian → guardians → users, all liveStudentDto.isRecordCompleteComputed at read time on every list/detail querystudents.service.integration.spec.ts — "reports a student incomplete once their only guardian is deleted"
Creating a person does not create an accountMost people on a school's roll — a sweeper, a very young pupil, an emergency contact — need a record and no login. Handing every one of them credentials creates accounts nobody asked for and nobody watches.The admission and staff forms carry an explicit "give this person a portal account" choice, off by default.PersonWriterService.buildInsert takes canLogin as a required argument with no default; PeoplePermissionsService.resolveSignInGrant decides itgrantSignIn on the three create bodies, and the older person.canLoginEvery create path states the value having first checked it maystudents.service.integration.spec.ts — "writes nothing at all when the grant is refused"
Granting sign-in requires Users_UPDATE, not the profile permissionA login-capable row with an email address is a route to a session: POST /auth/password/forgot is public, so anybody who can create one can obtain one. That is an identity change, not a record edit.An admissions clerk can admit pupils all day and cannot mint a single account.resolveSignInGrant and the six :id/sign-in routes, all through PeoplePermissionsService.can403 AUTH_FORBIDDEN on a grant without the permissioncan() lets a superadmin bypass; a raw membership test would deny them after a release that added codes without permissions:syncstudents.service.integration.spec.ts — "refuses to grant sign-in without Users_UPDATE"
Sign-in access says nothing about notificationsA parent with no portal account still has a phone number and a right to be told their child is ill. Filtering messages by login capability silently drops the people least able to find out another way.Every person addressed by a notification receives it, account or not.Absent from the notification audience resolver and the channel send pathNone — can_login is not consulted on any delivery pathcan_login means "may authenticate" and nothing elseNotification module specs
An invitation commits with the person it invitesA fire-and-forget send after the commit loses the invitation on a restart between the two: the person exists, believes they were invited, and nothing is scheduled.Somebody who is told they have been invited has been.PeopleAccountService.inviteOnCreate runs on the caller's transaction, through the notification outboxinvitation on the create response reports enqueued, never deliveredThe verification record, the event and its outbox row share the create's transactionstudents.service.integration.spec.ts — "grants, and invites, in one call"
Staff are never given a guardian- or pupil-scope roleA guardian-scope role shows the holder their children's rows and a pupil-scope role shows them their own pupil rows. Neither means anything for an employee, and granting one from a staff surface hands an employee a parent's or a pupil's view of the people domain.The pickers do not list such roles and an admission or edit that names one is refused. The Roles screen refuses to limit a role to guardians or pupils while any staff member holds it.STAFF_EXCLUDED_ROLE_SCOPES and isStaffAssignableScope in staff-roles.service.ts, read by both pickers, the admission and edit writes, the roles list and RoleService.setRowVisibility400 STAFF_ROLE_SCOPE_NOT_ASSIGNABLE; 409 ROLE_SCOPE_HELD_BY_STAFFThe set an edit replaces excludes these roles, so a grant the person holds through another profile survivesstaff.service.spec.ts, role.service.spec.ts
Choosing a staff member's roles needs Users_UPDATEThe roles a person holds are the authority they sign in with. Choosing them is an identity change, not an edit to an employment record.A clerk holding only Staff_CREATE or Staff_UPDATE can admit and edit staff all day and cannot hand anyone authority.StaffService.assertMayChooseRoles through PeoplePermissionsService.can, so a superadmin passes403 PEOPLE_ROLE_NOT_PERMITTEDThe check runs before the transaction on admission and as the second step of an edit's role changestaff.service.spec.ts
A teaching designation's roles come from the jobA teacher is a staff row with a teaching designation, and the teacher role follows the designation. A hand-picked role on top would let the two disagree.The create form hides the Role select for a teaching designation and on the Teachers route.StaffRolesService.assertExplicitAllowed400 STAFF_ROLE_TEACHER_JOB_ONLYstaff and teacher are derived by name, which is why both are system rolesstaff.service.spec.ts
An edit changes roles only when askedMoving a member to another designation should not silently strip a custom role chosen for them, and re-deriving by default would.Roles change on roleSource: "job" or "explicit" and never otherwise.StaffService.update calls changeRoles only when roleSource or rolePublicIds is presentThe response carries the resulting roles for a viewer holding Staff_UPDATEA designation change on its own writes no user_role rowstaff.service.spec.ts
Role changes are checked in both directions and never on yourselfAn actor who cannot hand out a role must not be able to strip it either, or emptying a peer's roles becomes a way to act above one's own level. Changing one's own roles, even to a subset, is a way to climb sideways into a peer role.An administrator cannot change their own roles, a superadmin's unless they are a superadmin, or the last superadmin role at all.StaffRolesService.changeRoles and replaceStaffRoles, over ActorAuthorityService.assertMayActOnAccount, assertMayAssignRole and assertNotLastSuperadmin403 PEOPLE_SELF_ROLE_CHANGE_FORBIDDEN, USER_SUPERADMIN_PROTECTED, PEOPLE_ROLE_NOT_PERMITTED, USER_LAST_SUPERADMIN_PROTECTEDThe last-superadmin check takes FOR UPDATE on the holders, so concurrent demotions serializeactor-authority.service.spec.ts, staff.service.spec.ts
Out-of-scope reads return 404, never 403A 403 confirms the record exists, turning the id space into an enumeration oracle against a roll of children.A guardian probing another family's student id learns nothing.PeopleAccessService.assertCanAccessEvery :id route on all three controllersNo NOT_YOUR_RECORD code exists anywhere in error-codes.tsguardians.service.spec.ts — "lets a guardian see only themselves, and 404s (never 403)"
Salary and health fields are omitted, not nulledA field that appears or vanishes by permission is a response-shape function of authorization every consumer would have to model; nulling would still leak the field's existence and shape.A colleague with Staff_READ alone sees no basicSalary key at all in the base response — not null, absent.Separate StaffSalaryDto/StudentMedicalDto, separate services, separate permission codesSeparate GET/PATCH .../salary and .../medical routesSTAFF_SALARY_COLUMNS never appears in PERSON_SELECTION or StaffDto's querystaff.service.spec.ts — "keeps salary out of the staff response, omits it without the permission, and returns it with the permission"
student_single_primary_guardian is a non-deferrable partial unique indexA deferrable constraint would let an intermediate two-primary state exist mid-transaction; not deferring forces the two-phase write pattern that never creates that state.Reassigning the primary always succeeds in one call.Partial unique index on (student_id) WHERE is_primaryPUT /students/:id/guardianswriteGuardianLinks inserts all rows non-primary, then flips onestudents.service.integration.spec.ts — "moves the primary flag between guardians in one transaction"
Deletion is profile-scopedOne person may hold two profiles (e.g. an eighteen-year-old student who is also a sibling's guardian); person-scoped deletion would remove one via a side effect of the other.Deleting a student never silently removes a guardian who happens to be the same person.hasNoLiveProfile counts across students/guardians/staff before touching usersDELETE on any of the three entity routesusers.deleted_at set only when the count reaches zerostudents.service.integration.spec.ts — "does not remove the person when they still hold another live profile"
total_salary has no coalesceA missing salary stored as 0.00 is indistinguishable from a genuine zero in a payroll sum.HR sees null, not a misleading रु 0, for an unset salary.generatedAlwaysAs(sql\basic_salary + allowances`)`, both columns nullable togetherStaffSalaryDto.totalSalarystaff_salary_pair_coherent CHECK keeps the pair coherentstaff.service.spec.ts — salary tests
Codes allocated by one atomic upsert, year in school timezonemax()+1 races under concurrent admissions; a UTC year would misfile admissions for 5h45m every New Year.Two officers admitting at once never collide on a number.code_counters upsert with ON CONFLICT DO UPDATE ... RETURNINGadmissionNumber/employeeCode on the create responsePeopleCodeService.allocatestudents.service.integration.spec.ts — "allocates consecutive admission numbers without collision"
Search pins pg_trgm.similarity_threshold per transactionIt is a SESSION setting; a pooled connection carries whatever the last borrower left.Search results are deterministic regardless of which pooled connection served the request.withSearchThreshold wraps the query in a transaction with set_config(..., true)Any search= query parameterstudents.service.ts, guardians.service.ts, staff.service.ts all route search through itstudents.service.integration.spec.ts — "finds a student by a misspelt name"
Every people cache key carries a scope tag and a view tagOmitting the view tag lets a wider-permission caller's rendering get served to a narrower one under one shared key.A teacher never receives HR's cached staff list even if both query identically.PeopleAccessService.cacheTagsEvery list endpoint's Redis keyCacheKeyUtil.build always receives both ["scope", ...] and ["view", ...]Comment-documented; no direct spec observed for the cache-poisoning case itself
Guardians and students hold no module permission by defaultGET /users is the whole-school directory; granting a guardian any read on it would hand every family the entire roll.A guardian's or student's own-record access runs entirely through object-level scope.GUARDIAN_PERMISSIONS = [], STUDENT_PERMISSIONS = []PeopleAccessService.scopeFor's guardian/student branchesRoleGuard still passes because the routes above them are gated by scope, not a module code, for these two roles' own-record readsseed-auth.ts
studentId is never reissued; admissionNumber isA transcript or an external system needs one identifier that never points at a different pupil later; the admission process needs a number a school migrating its roll can supply and, after a removal, reissue.A pupil's permanent id never changes even if their admission record is removed and re-created.students_student_id_unique (full unique index) vs students_admission_number_unique (partial, WHERE deleted_at IS NULL); separate code_counters scopes (student_id/student)StudentDto.studentId is response-only — no CreateStudentDto/UpdateStudentDto field exists for itPeopleCodeService.allocate("student_id", ...) runs on every create, unconditionallypeople-code.service.ts allocation logic; schema comments in people.ts
Staff salary on create is gated on key presence, not on valueSilently ignoring an unpermitted salary key would make a rejected write indistinguishable from a successful one that had nothing to save; an actor without the permission must not be able to probe whether the field is even accepted.An HR clerk without StaffSalary_UPDATE gets an explicit 403 rather than a staff record that quietly has no pay recorded.StaffSalaryService.resolveSalaryForCreate checks Object.hasOwn(dto, "salary") before inspecting its contentsPOST /staff salary: {} → 403; omitting the key → 201 with no salary setstaff.service.ts delegates the whole decision to StaffSalaryService rather than checking the permission itselfstaff.service.spec.ts
recordVisibility replaced includeDeletedA boolean cannot distinguish "records I normally work with," "only the removed ones," and "both" — the old boolean answered a different question than the one the "Show removed" checkbox asked, and returned every record instead of only the removed ones.An office searching for a removed pupil gets exactly the removed records, not the removed records mixed into everyone else.RECORD_VISIBILITIES = ["current","removed","all"], a dedicated query field on all three list DTOs?recordVisibility=removed on GET /students|/guardians|/staff; ?includeDeleted= still parses but is never readKept on each query DTO for one release because forbidNonWhitelisted would otherwise turn a bookmarked ?includeDeleted=true link into a 400apps/api/src/common/dto/record-visibility.ts (rationale in the file's own comment)
At most one live, active PrincipalA school profile, a report card header, and any future permission tied to "is this person the principal" all need one unambiguous answer, and no index can express "at most one row whose designation is a specific flagged row."An admin cannot assign the Principal designation to a second active staff member while one already holds it — the write is refused, naming who currently holds it.PrincipalInvariantService.assertSinglePrincipal, called from five verified write sites (staff create, staff update, bulk import, restore, employment-status-to-active)409 STAFF_PRINCIPAL_ALREADY_ASSIGNED on the write that would create a second principal; School Profile's principal field always reflects the current sole holder or nullNo schema constraint enforces this — enforced only in application code, at every write that could break itprincipal-invariant.service.ts's own docblock enumerates the five sites and the fixed lock order

12.4 Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
All three profiles carry an optimistic-concurrency versionTwo office staff editing the same pupil, guardian or employee at once cannot silently overwrite each other.A version counter per profile row, incremented by the bump_row_version BEFORE UPDATE trigger and compared by one shared versionOf/matchesVersion helper.Keep it in application code at each write siteTwelve update sites across eight files write these tables, two of them through a table selected at runtime that no grep finds. A hand-maintained increment has to be remembered at every one, forever, and forgetting it yields a check that silently does not run.The trigger fires on every UPDATE, so a bulk data repair invalidates every open form for every row it touches.
Account actions check "may this actor act on this account" separately from "would this leave no superadmin"A superadmin cannot be suspended, restored, or sent a reset link by an office clerk merely because a second superadmin happens to exist on file.One shared ActorAuthorityService.assertMayActOnAccount, consulted by ban, unban, and sendPasswordResetLink alike, alongside the pre-existing assertNotLastSuperadmin.Rely on the last-superadmin count aloneThe count answers "would the system still have a working superadmin", which any holder of the entity's _UPDATE permission satisfies trivially whenever two or more superadmins exist — it says nothing about whether that clerk was entitled to touch this particular account.Without the separate check, suspending, restoring, or resetting the credentials of the one account that could reverse the action would be reachable by anyone holding ordinary _UPDATE on any of the three entities.
Guardian restore requires Guardians_RESTORE; staff restore requires Staff_RESTORE; student restore is gated by Students_UPDATE——Gate all three restores under a matching _RESTORE permissionAn administrator granted Students_UPDATE but not Students_RESTORE can restore a soft-deleted student today, while the same shape of grant would not restore a guardian or staff member — a genuine inconsistency visible by comparing the three controllers.A permission model built on the assumption that _RESTORE is required everywhere would under- or over-grant for students specifically.
Guardian/staff :id params are plain strings; student :id uses ParseUUIDPipe——ParseUUIDPipe on every entity's :idA malformed guardian or staff id reaches the service and fails as a plain "not found" (the query simply matches nothing) rather than a 400 at the pipe.Slightly less precise error for a malformed id on those two entities; no correctness issue since the query still returns no row.
At least one guardian is required at admissionA pupil with no recorded family contact is a data gap the office cannot act on, not a legitimate partial record.StudentGuardiansService.assertGuardianSetValid refuses an empty set with 409 STUDENT_REQUIRES_ONE_GUARDIAN.Permit zero guardians, as an earlier version of this rule didAn admissions officer with incomplete family details at the desk cannot complete the admission until at least one guardian is entered.Low — the admission form's create-or-select guardian step is expected to run before submit either way.
Salary and medical data live in the base tables, gated by a separate read path, rather than separate tablesOne profile row per person, no join needed for the base record.Simpler schema; the field-level gate is enforced in the SELECT list, not by a table boundary.Split into staff_salary/student_medical tablesA future bulk export or raw query against staff/students must remember to exclude these columns itself — the table boundary would have made that structural.A careless SELECT * anywhere against these tables leaks gated fields; none of the reviewed service code does this, but the schema does not prevent it.
Fail-soft cache (getSoft/setSoft/delPatternSoft)A Redis outage degrades to slower reads, never an outage of the people directory.One shared pattern across all three services.Fail-closed cache (rethrow on Redis error)A failed invalidation leaves a stale list for up to 120 seconds — acceptable for a directory, not for a balance or a lock.A newly admitted student can be briefly absent from a cached list during a Redis outage.

12.5 Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
List (any)Empty stateNo rows match the filtersdata: [], count: 0Empty list, no errorqueryList in each service
Admit a studentFirst admission of the yearcode_counters has no row yet for (student, year)Counter row created via the upsert's INSERT branch, starts at 1STU-2026-0001people-code.service.ts
Admit a studentTen-thousandth admissionSequence reaches 10000Number widens past 4 digits rather than truncatingSTU-2026-10000, not a collision with STU-2026-1000people-code.service.ts (padStart, never lpad truncation)
Set guardiansDuplicate actionSame PUT body submitted twiceSecond call deletes and rewrites identically — idempotent by construction200 both times, same resulting setstudent-guardians.service.ts
Update (students)Concurrent actionTwo PATCHes race on the same studentSecond to reach the row lock sees a version mismatch (if the first already committed) or blocks briefly on the FOR UPDATE lock409 PEOPLE_STALE_RECORD for the loserstudents.service.ts
RestoreExpired stateRestoring a record deleted long enough ago that its code/email were reissuedRefused with the specific conflict code409 naming what to change firstpeople-deletion.service.ts
Access scopePermission mismatchGuardian role active but session somehow carries a stale/invalid activeRoleRoleGuard throws first (PERMISSION_ROLE_NOT_ASSIGNED/AUTH_ACTIVE_ROLE_REQUIRED) before the service is ever reached403role.guard.ts
Access scopeGuest/self limitationGuardian requests a student who is not theirsSame as record-missing404 STUDENT_NOT_FOUNDpeople-access.service.ts
Guardian lookupMissing dependencyPhone belongs to nobodyEmpty arrayOffice proceeds to createstudent-guardians.service.ts
Any listCache stale/missFirst read after a TTL expiry or an invalidationCache miss, query runs, result cached for another 120sSame response either way — cache is invisible to the callerredis.service.ts
Search (any)Unsupported filter or sort optionsort=medicalConditions on studentsRefused400 PEOPLE_INVALID_SORT_FIELDstudents.service.ts (STUDENT_SORTABLE allow-list)
Staff rolesConcurrent actionA chosen role is deleted, or limited to guardians or pupils, while an admission or edit is in flightThe role is read FOR SHARE in the writer's transaction; the delete and the narrowing take the row FOR UPDATE and wait400 ROLE_NOT_FOUND if the delete lands first, otherwise the grant completesstaff-roles.service.ts
Staff rolesDuplicate actionThe same explicit edit is sent twice, the second with the current versionThe second finds nothing to add or remove200, no audit entry, no stream closedstaff-roles.service.ts (replaceStaffRoles)
Staff rolesPermission mismatchA teacher who is also a parent has a role editThe guardian grant is outside the managed set and survives; the roles list does not show it200staff-roles.service.ts (managedRoles)
Staff rolesGuest/self limitationAn administrator edits their own rolesRefused, even when the new set is a subset of what they hold403 PEOPLE_SELF_ROLE_CHANGE_FORBIDDENstaff-roles.service.ts (changeRoles)
SearchQueue failureN/A — this domain has no queue———

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
Admit a studentGuardian rows (if linking), code_countersusers, students, student_guardian, code_countersSweeps students:*NoneFull StudentDto
List studentsstudents, users, correlated student_guardian/guardians/users subqueriesNoneReads/writes students:list:...NoneStudentDto[], totalCount
Update salarystaff current rowstaff salary columnsSweeps staff:*NoneStaffSalaryDto
Soft deleteProfile row, users liveness countProfile table, conditionally users/account/sessionsSweeps entity *:*Nonevoid (ResponseDto with data: null)

12.7 Experience Quality Checklist

  • The doc explains what the actor is trying to accomplish — admit a pupil, register a parent, onboard an employee, and manage each safely afterward.
  • The doc explains what the backend does that the actor does not see — atomic code allocation, the two-phase primary-guardian write, computed completeness, cache tag composition.
  • The doc covers every minor flow and branch — see §12.1 and §12.5.
  • The doc includes user, admin, and system flows where applicable (this domain has no guest or automated-worker flows — every entry point is an authenticated, permissioned human action).
  • The doc explains business logic, tradeoffs, and rationale — §12.3, §12.4.
  • The doc maps every flow to API routes and backend side effects — §4, §9, §12.6.
  • The doc includes diagrams appropriate to each flow type — §5-§7, §12.2.
  • The doc covers all edge cases and failure recovery — §10, §12.5.

13. Completion Checklist

  • Every feature, minor action, and submodule capability is listed — §4, §12.1.
  • Every actor has allowed and forbidden behavior — §3.
  • Every major and minor flow includes steps, branches, and diagrams — §5, §6.
  • Every lifecycle has a transition table and state diagram — §7.
  • Every flow links to the API and backend docs — §4, and inline throughout §5.
  • TDD dependencies are called out where they shape behavior — no sibling TDD doc exists for this module in this repository; none is referenced.

See Also

On this page

People Features and Flows1. Documentation Evidence2. Feature Summary3. Actor Matrix4. Capability Matrix5. User-Facing Flows5.1 Admit a studentSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.2 Attach a sibling to an existing guardianSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.3 Update a student, guardian, or staff memberSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.4 Replace a student's guardian setSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.5 Read a student's health record or a staff member's salarySummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.6 Suspend and restore a person's sign-in (students, guardians, staff)SummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.7 Email a password-reset linkSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.8 Give somebody a portal account, or take it away (students, guardians, staff)SummaryPreconditionsMain FlowBranches and Edge Cases5.9 Move or end a graduate's portal accessSummaryPreconditionsMain FlowBranches and Edge Cases5.11 Edit a staff member's salary and bank details from the staff form5.12 Read the headline people counts5.13 Choose a staff member's rolesSummaryPreconditionsMain FlowSequence DiagramBranches and Edge Cases5.14 Create a parent with sign-in access from the admin panel5.15 Staff and teacher lists after a write5.16 Teacher routes6. Admin Flows6.1 Create6.2 List6.3 Read detail6.4 Update6.5 Reorder6.6 Activate/deactivate6.7 Soft delete6.8 Restore6.9 Export/import6.10 Moderation6.11 Manual retry7. Lifecycle and State Transitions9. Data and Side Effects by Flow10. Error and Recovery Flows11. Diagrams Required Per Module12. Mandatory Feature and Flow Deep-Dive Pack12.1 Feature Inventory With Minor Behaviors12.2 Business Process Diagram Pack12.3 Business Rules and Policy Traceability12.4 Tradeoffs and Product Rationale12.5 Flow Edge-Case Matrix12.6 Flow-to-Data Trace12.7 Experience Quality Checklist13. Completion ChecklistSee Also