Skip to content

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 Guardian Prisma 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 to String? @db.Uuid + FK when the Files spec lands.
  • New src/guardians/ module: GuardiansController, GuardiansService, guardians.queries.ts, guardians.swagger.ts, scope sub-DTOs under dto/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 guardians entity in prisma/seed/rbac-catalogue.ts with anagraphic + documents scopes and create / delete actions.
  • Curation registration: GUARDIAN_COMPLETION_REQUIRED in src/common/constants/completion-required-fields.ts, plus EntityKey.GUARDIANS in COMPLETION_REQUIRED_REGISTRY.
  • Migration generating guardians table + 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." No userId column, 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 same personUuid) = 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

  1. Separate model, not a Referent flag. Confirmed by user 2026-04-27. Guardian and Referent are distinct domain concepts and must not share storage.
  2. No userId / no platform access. Per US-18 + the invitations design. Guardian is data-only.
  3. Owned by Student via FK; cascade delete. A guardian without a student is meaningless. Deleting a student wipes its guardians.
  4. Multitenant via tenantId denormalization. Standard for this codebase — Guardian carries tenantId for query-shape consistency with every other tenanted table, even though studentId → student.tenantId would derive it. Defensive: prevents accidental cross-tenant leaks if a query forgets the join.
  5. 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.
  6. File-mock columns. Same shape as Phase A: String? placeholder until Files spec lands. The §11.4 anyOf rule for completeness reads them.
  7. Scopes: anagraphic + documents. Mirrors the Referent scope split. No contacts, sensitive, or students scope — Guardian has no contact info, no sensitive data, and is owned by exactly one Student via the FK.
  8. Actions: create + delete. Per RBAC convention (update is implicit via scope-write, read is implicit via scope-read). import not listed — no v1 import.
  9. Route shape: nested /students/:studentId/guardians. Top-level /guardians adds no value — the only consumers are the student-detail UI and the missingFields aggregator. Nested URLs match the data-ownership model and let studentId go through ParseUUIDPipe once.
  10. Both Admin and Referent can create guardians. Per the doc: "Fields required at creation — entered by Admin or Referent". RBAC: any role granted guardians.create can hit POST. 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

GUARDIANS: 'guardians',

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 existing RecordAccessContext pattern (mirroring studentsForAccessContext).
  • 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.GUARDIANS
  • getPrismaDelegate() = this.prisma.guardian
  • getScopeFieldMappings() — array shape from GUARDIAN_SCOPES.anagraphic + array shape from GUARDIAN_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's RecordAccessContext permits writing on the target studentId (loads the Student, checks tenant + access). Throws STUDENT_NOT_FOUND if not.
  • Year-writability: checked through the parent Student's academicYearId to 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.md for entity/scope/action conventions.
  • docs/05-crud-patterns.md for module file structure.
  • docs/12-migrations.md for the hazard checklist.