Guardian Model — Data-Only Records on a Student¶
Status: Proposed (Phase B follow-up to missing-fields-design §11.3)
Date: 2026-04-27
Source user story: US-18 Manage Guardians (ClickUp 8cnxf2d-4615 in doc 8cnxf2d-7815)
1. Problem¶
US-18 defines Guardian as a separate domain concept from Referent: a non-logging-in person attached to a student (typical use case: a grandparent or trusted family friend who can pick up the student but does not need platform access). Today the schema has only Referent + StudentReferentLink, which conflates "guardian" with "logging-in parent contact."
The doc "Grace Period — Required Fields by Entity" §Guardian Record lists three creation-required fields (name, surname, date_of_birth) and one completeness rule (passport-file XOR id-card-file). Without a Guardian model these rules cannot be enforced and the missingFields curation has nowhere to register them.
Confirmed 2026-04-27: Guardian is not a Referent. The two entities must be modeled separately; merging them with a flag is rejected.
2. Scope¶
In scope¶
- New
GuardianPrisma model — flat per(studentId, name, surname, dateOfBirth), attached to a Student via FK, cascade on student delete. - Two interim file-mock columns —
passportFileId String?,identityCardFileId String?— same shape as the Phase A file-mocks on Student / Teacher / Staff / Referent. Retyped toString? @db.Uuid+ FK when the Files spec lands. - New
src/guardians/module:GuardiansController,GuardiansService,guardians.queries.ts,guardians.swagger.ts, scope sub-DTOs underdto/scopes/. - Endpoints under
/students/:studentId/guardians— list, create, get, patch, delete. The route is nested because Guardians are always owned by a Student; there is no top-level discovery use case. - RBAC: new
guardiansentity inprisma/seed/rbac-catalogue.tswithanagraphic+documentsscopes andcreate/deleteactions. - Curation registration:
GUARDIAN_COMPLETION_REQUIREDinsrc/common/constants/completion-required-fields.ts, plusEntityKey.GUARDIANSinCOMPLETION_REQUIRED_REGISTRY. - Migration generating
guardianstable + indexes.
Out of scope (explicitly deferred)¶
- Guardian invitations / login. Confirmed in the invitations design doc (
2026-04-20-invitations-design.md§Out-of-scope): "Guardians do not log in, do not receive credentials, do not appear in the referent list." NouserIdcolumn, no Invitation row support, no email column. - Guardian self-service. Referents (per RUS-2) and Admins manage guardian rows on behalf of the student. The Guardian themselves never authenticates.
- Guardian import. No CSV import path in v1 — guardians are entered through the student detail UI. If batch entry becomes necessary it is a follow-up spec.
- Cross-tenant or cross-year Guardian deduplication. Each
(student, person)is one row. Same person across two students = two rows. Same person across two academic years (different Student rows for the samepersonUuid) = two rows. The data is small enough that duplication beats join complexity. - Custom fields on Guardian. No
customFields Json?column in v1. Add only if a real product need surfaces. - Guardian↔Tenant unique constraint by document number. Doc identity is captured by file-mock id only; uniqueness is not enforced at the schema level.
3. Decisions log¶
- Separate model, not a Referent flag. Confirmed by user 2026-04-27. Guardian and Referent are distinct domain concepts and must not share storage.
- No
userId/ no platform access. Per US-18 + the invitations design. Guardian is data-only. - Owned by Student via FK; cascade delete. A guardian without a student is meaningless. Deleting a student wipes its guardians.
- Multitenant via
tenantIddenormalization. Standard for this codebase — Guardian carriestenantIdfor query-shape consistency with every other tenanted table, even thoughstudentId → student.tenantIdwould derive it. Defensive: prevents accidental cross-tenant leaks if a query forgets the join. - Not academic-year-scoped. Guardian rows are tied to the student row (which IS year-scoped). If a student is "promoted" to next year as a new Student row for the same
personUuid, the admin re-creates the guardians on the new student. Same UX as departments/grades — confirmed via Referent's flat-per-tenant approach for similar reasons. - File-mock columns. Same shape as Phase A:
String?placeholder until Files spec lands. The §11.4 anyOf rule for completeness reads them. - Scopes:
anagraphic+documents. Mirrors the Referent scope split. Nocontacts,sensitive, orstudentsscope — Guardian has no contact info, no sensitive data, and is owned by exactly one Student via the FK. - Actions:
create+delete. Per RBAC convention (updateis implicit via scope-write,readis implicit via scope-read).importnot listed — no v1 import. - Route shape: nested
/students/:studentId/guardians. Top-level/guardiansadds no value — the only consumers are the student-detail UI and the missingFields aggregator. Nested URLs match the data-ownership model and letstudentIdgo throughParseUUIDPipeonce. - Both Admin and Referent can create guardians. Per the doc: "Fields required at creation — entered by Admin or Referent". RBAC: any role granted
guardians.createcan hitPOST. Tenant-admin and Referent role presets get the action; Teacher and Staff do not.
4. Data model¶
/// A non-logging-in person attached to a student — typically a relative or
/// trusted contact authorized for pickup but without platform access.
/// Distinct from Referent: Referents have email + login; Guardians do not.
/// Owned by exactly one Student; deleted when the Student row is deleted.
model Guardian {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
studentId String @map("student_id") @db.Uuid
// ── Anagraphic scope ──
firstName String @map("first_name")
lastName String @map("last_name")
dateOfBirth DateTime @map("date_of_birth") @db.Date
// ── Documents scope ──
passportFileId String? @map("passport_file_id") /// File-mock placeholder; retyped to Uuid + FK when Files spec lands
identityCardFileId String? @map("identity_card_file_id") /// File-mock placeholder; retyped to Uuid + FK when Files spec lands
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
@@index([tenantId])
@@index([studentId])
@@map("guardians")
}
Plus relation back-references on Tenant (guardians Guardian[]) and Student (guardians Guardian[]).
Field naming alignment¶
The doc uses name / surname / date_of_birth / passport_file_id / identity_card_file_id. The schema normalizes to firstName / lastName / dateOfBirth / passportFileId / identityCardFileId — same convention applied across Student, Teacher, Staff, Referent.
5. RBAC¶
prisma/seed/rbac-catalogue.ts¶
// Add to ENTITIES:
{ key: 'guardians', label: 'Guardians', description: 'Non-logging-in persons attached to a student', sortOrder: <next> },
// Add to SCOPES:
guardians: [
{ key: 'anagraphic', label: 'Anagraphic Data', description: 'Basic guardian information', sortOrder: 1 },
{ key: 'documents', label: 'Document Data', description: 'Guardian document information', sortOrder: 2 },
],
// Add to ACTIONS:
{ entityKey: 'guardians', key: 'create', label: 'Create Guardian', description: 'Create a guardian on a student', sortOrder: 1 },
{ entityKey: 'guardians', key: 'delete', label: 'Delete Guardian', description: 'Delete a guardian', sortOrder: 2 },
// Add to ACTION_REQUIREMENTS:
'guardians.create': { scopeKeys: ['guardians.anagraphic', 'guardians.documents'] },
'guardians.delete': { scopeKeys: ['guardians.anagraphic', 'guardians.documents'] },
// Add to FIELD_MAPPINGS:
{ entityKey: 'guardians', scopeKey: 'anagraphic', tableName: 'guardians', fields: GUARDIAN_SCOPES.anagraphic },
{ entityKey: 'guardians', scopeKey: 'documents', tableName: 'guardians', fields: GUARDIAN_SCOPES.documents },
src/common/constants/scope-fields.ts¶
export const GUARDIAN_SCOPES = {
anagraphic: ['firstName', 'lastName', 'dateOfBirth'] as const,
documents: ['passportFileId', 'identityCardFileId'] as const,
} as const;
// Add to ENTITY_SCOPE_REGISTRY:
guardians: new Set(Object.keys(GUARDIAN_SCOPES)),
src/common/constants/entity-keys.ts¶
Role presets¶
- Admin (tenant-admin tier): WRITE on both scopes,
create+delete. - Referent: WRITE on both scopes for guardians of their own students,
create+delete. Per-student authorization is enforced in service via the existingRecordAccessContextpattern (mirroringstudentsForAccessContext). - Teacher / Staff: no access by default.
6. Module layout¶
src/guardians/
├── guardians.module.ts
├── guardians.controller.ts # Nested under /students/:studentId/guardians
├── guardians.service.ts # Extends BaseTenantedCrudService<Guardian, ...>
├── guardians.queries.ts # studentGuardiansInclude + lookup helpers
├── guardians.swagger.ts
├── dto/
│ ├── create-guardian.dto.ts # Aggregates the two scope DTOs
│ ├── update-guardian.dto.ts # PartialType
│ ├── guardian-response.dto.ts # AggregateResponseDto-shaped: id + 2 scope groups + timestamps
│ └── scopes/
│ ├── guardian-anagraphic.dto.ts
│ └── guardian-documents.dto.ts
└── index.ts
Endpoints¶
All require JWT + nested :studentId validation. Cross-tenant + cross-student probe attempts return 404.
| Method | Path | Decorators | Description |
|---|---|---|---|
GET |
/students/:studentId/guardians |
@RequireScopes(GUARDIANS, 'read') |
List guardians for a student. |
POST |
/students/:studentId/guardians |
@RequireAction(GUARDIANS, 'create') |
Create a guardian on the student. |
GET |
/students/:studentId/guardians/:id |
@RequireScopes(GUARDIANS, 'read') |
Get one guardian. 404 if not on this student. |
PATCH |
/students/:studentId/guardians/:id |
@RequireScopes(GUARDIANS, 'write') |
Partial update. FieldWriteGuard enforces per-field scope. |
DELETE |
/students/:studentId/guardians/:id |
@RequireAction(GUARDIANS, 'delete') |
Hard delete. |
Per-student access (referents only seeing their own students' guardians) is enforced in GuardiansService.findAllForAccessContext via the same RecordAccessContext pipeline as students. The :studentId is verified to match the caller's student-access scope before the guardian rows are even queried.
7. DTOs¶
Standard scope split, one file per scope. CreateGuardianAnagraphicDto requires all three fields (R-creation per the doc); UpdateGuardianAnagraphicDto = PartialType(...). Documents scope: both file fields optional.
GuardianResponseDto extends AggregateResponseDto with id, anagraphic (response shape with missingFields: string[]), documents (same), createdAt, updatedAt.
GuardianAnagraphicResponseDto.missingFields and GuardianDocumentsResponseDto.missingFields use the same @ApiProperty({ type: [String] }) declaration as every other registered entity. The build-time completion-required-fields.spec.ts is extended with [EntityKey.GUARDIANS]: { anagraphic: GuardianAnagraphicResponseDto, documents: GuardianDocumentsResponseDto } so the drift check covers Guardians.
8. Service¶
GuardiansService extends BaseTenantedCrudService<Guardian, CreateGuardianDto, UpdateGuardianDto, GuardianResponseDto> with:
entityKey = EntityKey.GUARDIANSgetPrismaDelegate() = this.prisma.guardiangetScopeFieldMappings()— array shape fromGUARDIAN_SCOPES.anagraphic+ array shape fromGUARDIAN_SCOPES.documents. No custom mapping function needed (no joined relations).getQueryInclude()— none. Guardians have no joined relations to surface in responses.beforeCreate— verifies the caller'sRecordAccessContextpermits writing on the targetstudentId(loads the Student, checks tenant + access). ThrowsSTUDENT_NOT_FOUNDif not.- Year-writability: checked through the parent Student's
academicYearIdto keep "no edits to past years" parity.
The base service's flattenDto already handles nested scope DTOs into a flat Prisma data object, so no custom create/update path is needed.
9. Curation registration¶
src/common/constants/completion-required-fields.ts:
export const GUARDIAN_COMPLETION_REQUIRED = {
// US-18: name + surname + dateOfBirth are R-creation, so always present at
// completeness check time. Listed here for symmetry; the engine will only
// flag them if a buggy create-path lets them slip through as null/empty.
anagraphic: ['firstName', 'lastName', 'dateOfBirth'] as readonly MissingFieldRule[],
// US-18: documents — passport-file OR id-card-file satisfies completeness.
documents: [
{
anyOf: [['passportFileId'], ['identityCardFileId']],
},
] as readonly MissingFieldRule[],
} as const;
// Add to COMPLETION_REQUIRED_REGISTRY:
[EntityKey.GUARDIANS]: GUARDIAN_COMPLETION_REQUIRED,
This activates the [guardian]-tagged registry entry placeholder in the missing-fields-design spec §11.5. No engine change needed — anyOf is already shipped per Phase C.
10. Migration¶
Single Prisma migration. Generated SQL should be:
CREATE TABLE "guardians" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"student_id" UUID NOT NULL,
"first_name" TEXT NOT NULL,
"last_name" TEXT NOT NULL,
"date_of_birth" DATE NOT NULL,
"passport_file_id" TEXT,
"identity_card_file_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "guardians_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "guardians_tenant_id_idx" ON "guardians"("tenant_id");
CREATE INDEX "guardians_student_id_idx" ON "guardians"("student_id");
ALTER TABLE "guardians" ADD CONSTRAINT "guardians_tenant_id_fkey"
FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE;
ALTER TABLE "guardians" ADD CONSTRAINT "guardians_student_id_fkey"
FOREIGN KEY ("student_id") REFERENCES "students"("id") ON DELETE CASCADE;
Hazard checklist (chapter 12):
- New table only — no ADD COLUMN NOT NULL on existing tables ✓
- No new uniqueness constraints on populated tables ✓
- No drops, no renames, no type changes ✓
- New FKs reference existing tables; the guardians table is empty when the FKs are added ✓
Safe to commit without amendments.
11. Testing¶
| Suite | File | Coverage |
|---|---|---|
| Service | guardians.service.spec.ts (new) |
Tenant-scoping; access-context check on create; cascade-delete via parent student delete; missingFields integration via base-service flow. |
| Controller | guardians.controller.spec.ts (new) |
Route delegation; :studentId ParseUUIDPipe; 404 on cross-student probe. |
| RBAC seed | extend the existing seed-snapshot test if any | Catalog rows for guardians entity / scopes / actions present after seeding. |
| Curation | extend completion-required-fields.spec.ts |
EntityKey.GUARDIANS covered in REGISTERED_DTOS; both scope DTOs declare missingFields. |
12. Open questions¶
- Should referents see other referents' guardians? A guardian linked to Student A might also be a guardian on Student B (same person, two students). Today our access policy filters by student ownership; if Referent X is on Student A but not B, X won't see B's guardian rows even when the same person. Defaulting to: yes, scope by student ownership; cross-student visibility is not a feature. Flag if product wants cross-student aggregation.
- Soft delete vs hard delete? Hard delete in v1. Audit trail (US-34) is the future home for "deleted guardian" history. If the Files spec lands first and orphan-cleanup-on-delete becomes critical, revisit.
- Hard cap on guardians per student? None proposed. Most students have 0–2 guardians; a high count would surface a UX concern but no schema-level cap is needed.
13. Cross-references¶
docs/superpowers/specs/2026-04-27-missing-fields-design.md§11.3 (prereq spec slot) and §11.5 (target curation entry).docs/superpowers/specs/2026-04-20-invitations-design.md"Guardians" out-of-scope clause.docs/04-rbac.mdfor entity/scope/action conventions.docs/05-crud-patterns.mdfor module file structure.docs/12-migrations.mdfor the hazard checklist.