Skip to content

Referent self-only writes + readonly email (v1)

1. Problem distillation

  • Product decision for v1: a referent session may not add or delete other referents, and may modify only their own referent row — with email readonly even on their own row. Admin-side referent management is untouched.
  • "May not add / delete" is already true for the standalone routes: the referent preset holds neither the referents.create nor the referents.delete action (REFERENT_ACTION_KEYS in prisma/seed/roles.ts is {attendance.justify, communications.send}), and the student-side write surface cannot add referents (UpdateStudentDto carries no referents block; linking happens only at admin create/import). Two shipped-in-tree features violate the rest of the decision:
  • Unclaimed co-parent editing (QA batch 2026-08-07 W1): a referent may PATCH an unclaimed (userId: null) co-parent's identity + contacts blocks, via the ReferentsPolicy referent-branch OR-limb + the co-parent branch in assertUpdateWriteAllowed.
  • Family-session merge (same batch): PATCH /referents/:id + contacts.mergeIntoExisting lets a referent collapse a same-family placeholder into a survivor — which deletes a referent row, the one referent-reachable delete path in the system.
  • Email is currently self-editable (with invitation invalidation + REFERENT_EMAIL_IN_USE 409 on collision). Product wants it readonly for referent sessions; the school (admin) remains the only party that can change a referent's login identity.
  • Net effect: the referents entity returns to the pre-QA-batch self-or-admin write doctrine, plus one new field-level carve-out (email) on the self path.

Success criteria (observable behavior that proves this works): - A referent session PATCHing any referent row that is not their own — including an unclaimed co-parent on a shared child — gets the same 404/403 a stranger's row gets today. - A referent session PATCHing their own row succeeds for identity / contacts / health fields, but a contacts.email value different from the current one is rejected 403; sending the current email back (FE full-form PATCH) is a tolerated no-op. - No referent-session request path can create or delete a Referent row: POST /referents and DELETE /referents/:id stay 403 (action-gated, pinned by e2e), and the merge path is privileged-only. - Admin behavior is unchanged: PATCH any row, change email (invitation invalidation intact), 409 on collision, mergeIntoExisting merge with the existing privileged slot-file semantics. - GET /referents/me and self document routes keep working unchanged.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No change to guardians (lightweight caregiver contacts under students.referents_and_guardians) — guardians are not referents; referent write access to the guardians sub-resource is untouched. - No change to the teacher read branch on ReferentsPolicy (referents of taught students stay readable, slot-file metadata stays masked) or to admin/principal/department_head reach. - No change to referents.students link-flag semantics (already admin-write-only) or to the student-side students.referents_and_guardians READ projection (family FE keeps seeing co-parent summaries on the child profile). - No RBAC-seed delta — this is deliberately a policy/fence-level narrowing (see §6). No reseed, no migration. - No revisit of invitation lifecycle on admin email change (EMAIL_CHANGED invalidation stays as is).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
docs/superpowers/specs/2026-08-07-referent-qa-bugs-design.md (W1) + src/referents/referents.policy.ts / referents.service.ts The exact seams this spec closes: the referent-branch OR-limb, unclaimedCoParentWhere, the co-parent branch in assertUpdateWriteAllowed, the non-privileged merge eligibility limbs. Reverting is deleting these seams, not inventing new ones. It's a partial revert: the rest of W1 (409 REFERENT_EMAIL_IN_USE, admin merge, invitation inheritance, audit event) survives.
Referent scope narrowing 2026-08-06 (prisma/seed/roles.ts referent preset comments; memory project-referent-scope-narrowing) The product-driven-narrowing playbook: narrow, keep defense-in-depth, flip e2e pins to 403/404, amend the FE guide in place. That narrowing was seed-level (scope revoke → reseed). This one is fence/policy-level with zero seed delta — the write scopes must survive for self-edit.
Teacher/staff self-service (src/common/constants/self-editable-scopes.ts, assertBodyScopesSelfEditable, src/teachers/teachers.controller.ts /me block) The "self-edit is identity-based, not role-grant-based" doctrine and the block-level allowlist precedent. Referents already self-edit through PATCH /referents/:id + policy narrowing (no /referents/me PATCH), and the email carve-out is field-level inside a writable blockSELF_EDITABLE_SCOPES only fences whole blocks, so it can't express this.
students.assignment W→R scope split (2026-05-27, seed comments at prisma/seed/roles.ts REFERENT_READ_SCOPE_KEYS) The established answer to "referent must read but not write these fields". Splitting referents.contacts into an email-only scope would cost a new scope key + reseed + FE /permissions churn for a single field. A service fence is the proportionate tool; the module already special-cases email out of REFERENT_COMPLETABLE_COLUMNS (src/referents/referents.service.ts ~line 58), so "email is not like the other contacts fields" is precedented in this exact file.
Teacher people-visibility narrowing partial rollback (2026-08-04, memory project_teacher_people_visibility_narrowing) Precedent for rolling back part of a shipped-in-tree widening on product's say-so, keeping the write-fence half. n/a — fits.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Unchanged — all touched queries already compose tenantId via ReferentsPolicy.buildBase / explicit WHEREs. No new query shapes.
Academic-year scope no n/a — referents are NOT academic-year-scoped (module doctrine, see CLAUDE.md module map).
RBAC entity key existing EntityKey.REFERENTS No delta.
Scopes existing (referents.identity/contacts/documents/health/students) Reused unchanged — referent keeps WRITE on identity/contacts/documents/health for self-edit. Revoking contacts WRITE would kill legitimate self-edit of phones/address; email is fenced below scope granularity.
Actions none No new actions; the absent referents.create/referents.delete grants are what already blocks add/delete — pinned by e2e, not changed.
Service base custom (ReferentsService, not BaseTenantedCrudService) Changes land in update() / assertUpdateWriteAllowed() / merge eligibility, plus the ReferentsPolicy referent branch.
queries.ts shape existing referentByEmail unchanged; no new queries. unclaimedCoParentWhere (policy file) is deleted with its last consumers.
Error codes one new: REFERENT_EMAIL_READ_ONLY (403) New ErrorCode entry + messages{en,it} in src/common/i18n/error-messages.catalog.ts + error-examples entry. Params: none (the field is fixed). Generic ACTION_NOT_PERMITTED would be indistinguishable from "not your row"; FORBIDDEN_FIELDS means "whole scope block not writable" to the FE. BE owns user-facing copy (backend-error-i18n doctrine), so a precise message is the contract.
DTO conventions existing UpdateReferentDto unchanged (contacts.email stays in the DTO — admin uses it; mergeIntoExisting stays — admin uses it). Swagger JSDoc updated (public copy).
File-backed sub-resources n/a Document routes untouched (ReferentsDocumentsPolicy is already self-only for referents).
Custom fields no n/a — no custom-field surface change.
Profile completeness no n/a — REFERENT_COMPLETABLE_COLUMNS and the completeness derive are untouched.

4. Data model plan

Schema deltas

  • None.

Migration shape

  • No migration. Purely behavioral (policy + service fence + error catalog).

Indexes and uniqueness

  • None. tenantId_email uniqueness on Referent unchanged.

5. API surface

No routes added or removed. Behavioral deltas on existing routes:

Verb Path Decorators (unchanged) Behavioral delta
PATCH /referents/:id @RequireScopes(REFERENTS,'write') + @AppliesPolicy(ReferentsPolicy) Referent sessions: target must be self (userId === ctx.userId) — the unclaimed co-parent branch is deleted; a changed contacts.email from a non-privileged caller → 403 REFERENT_EMAIL_READ_ONLY (same-value email tolerated as no-op); mergeIntoExisting eligibility → privileged-only. Admin: unchanged (incl. 409 REFERENT_EMAIL_IN_USE + merge).
GET /referents/:id @RequireScopes(REFERENTS,'read') + @AppliesPolicy(ReferentsPolicy) Referent sessions resolve self only — the co-parent OR-limb comes out of the policy (see §8 P1 for the read-vs-write decision). Teacher/staff-side reach and non-self slot-file masking unchanged.
POST /referents @RequireAction(REFERENTS,'create') No code change — already 403 for referents; e2e pin added/kept.
DELETE /referents/:id @RequireAction(REFERENTS,'delete') No code change — already 403 for referents; e2e pin added/kept.

Swagger considerations

  • ApiUpdateReferent / ApiGetReferent (and the controller JSDoc, which is public copy): drop the unclaimed-co-parent language; document the referent-session email-readonly rule and the new 403 example.
  • Error-examples catalog gains REFERENT_EMAIL_READ_ONLY.
  • mergeIntoExisting field JSDoc in UpdateReferentDto: re-word to admin-only semantics (it currently explains family-session eligibility).

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none
Role grants (roles.ts) none — the referent preset keeps WRITE on referents.identity/contacts/documents/health (needed for self-edit) and keeps holding no referents.create/referents.delete actions. Comment on REFERENT_WRITE_SCOPE_KEYS amended to record the email carve-out + self-only fence.
*_SCOPES runtime constant none

No reseed, no expected-preset-grants.ts delta. The narrowing is enforced by ReferentsPolicy + the service write fence, mirroring how self-or-admin was enforced before the QA batch. (FE note: because /permissions still reports referents.contacts: WRITE, the FE cannot derive email-readonly from the grants payload — the FE guide must state it explicitly.)


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Field-write granularity is the scope block (FieldWriteGuard, SELF_EDITABLE_SCOPES) Fencing a single field (contacts.email) inside a block the caller may otherwise write, at the service layer A dedicated scope key for one field costs a catalogue entry + reseed + FE /permissions churn; the module already treats email as special (REFERENT_COMPLETABLE_COLUMNS excludes it; email drives invitation identity) The rule lives in ReferentsService, invisible to /permissions — mitigated by FE-guide contract + a dedicated error code + unit/e2e pins
Reject-on-forbidden-write (fences in this module throw) vs silent-ignore (student placement "silent reset" precedent) Explicit 403 REFERENT_EMAIL_READ_ONLY when the value differs; no-op tolerated when equal Silently dropping a changed email would let the FE believe a login-identity change succeeded — worst possible failure mode for credentials FE full-form PATCHes must send the unchanged email (or omit the field); tolerance-on-equal keeps this cheap
QA-batch W1 contract (co-parent completion is a family feature) Deleting the family-session write reach + family merge wholesale rather than gating them behind a flag Product v1 decision; keeping dead eligibility limbs "just in case" contradicts the module's fail-closed hardening history (PPL-1/2/3) Placeholder co-parents can now only be completed by the school; family merge becomes an admin tool

8. Pushback log

US says Conflicts with Proposed instead Status
"may not add or delete other referents" (implies a change is needed) Add/delete were already impossible for referent sessions on the standalone routes (no create/delete action grants; no student-side add path) Treat add/delete as verify-and-pin (e2e), not new fences; the real deltas are the merge path (the one hidden delete) and the co-parent write reach Resolved — this spec
"can only modify their own row" (says nothing about reads) ReferentsPolicy referent branch currently also grants read reach to unclaimed co-parents (GET /referents/:id), added as one feature with the editing Revert read reach together with write reach — the limb existed only to power editing; family FE still sees co-parent summaries via the child profile's referents_and_guardians READ block, so no display regression. Resolved (Q1 — full revert, Fabio 2026-08-10)
"email should remain readonly" (no error-shape guidance) n/a Dedicated 403 REFERENT_EMAIL_READ_ONLY with it/en copy; same-value no-op tolerated Resolved (Q2/Q3, Fabio 2026-08-10)

9. Deferrals

  • Placeholder co-parent data completion by families — deleted here; if product later wants families to fill in a co-parent's name/phone without full row-edit rights, that's a new purpose-built endpoint (not a policy re-widening) — follow-up: revisit only on a product ask.
  • PPL-8 residual (collection files transferring on family merges) — becomes moot for family sessions (family merges no longer exist); the privileged merge keeps its current file semantics. Close PPL-8 as overtaken when this lands — follow-up: note in project_full_audit_2026_08_08 memory on execution.
  • /referents/me PATCH route (symmetry with /teachers/me, /staff/me) — not needed; referent self-edit keeps riding PATCH /referents/:id + policy. Revisit only if the FE asks for the /me ergonomics.
  • Teacher/staff self-service email editability (SELF_EDITABLE_SCOPES allows the whole contacts block, including institutionalEmail, on /teachers/me + /staff/me) — out of scope here but the same product logic plausibly applies; surfaced for a separate product decision — follow-up: ask product alongside this sign-off.

10. Open questions

Blockers requiring user resolution before code starts. Must be empty (all resolved) before sign-off.

All four resolved by adopting the recommendations (Fabio's "go on with the plan" ack, chat 2026-08-10):

  • Q1 — Co-parent READ reach: full revert — the unclaimed-co-parent limb comes out of ReferentsPolicy entirely; a referent resolves self only on GET /referents/:id (other rows are hidden 404s, as pre-QA-batch).
  • Q2 — Error shape: new dedicated REFERENT_EMAIL_READ_ONLY (403) with en/it copy.
  • Q3 — No-op tolerance: sending the current email in contacts.email passes silently; only a differing value rejects.
  • Q4 — Merge feature: kept, admin-onlymergeIntoExisting survives as an operational dedupe tool; all non-privileged eligibility limbs (survivor-reach probe, in-tx family bound, family-session slot-file deletion split) are deleted.

11. Verification plan

  • Unit specs:
  • src/referents/referents.policy.spec.ts — referent branch resolves to self-only WHERE (limb removal per Q1); unclaimedCoParentWhere specs deleted with the helper.
  • src/referents/referents.service.spec.ts — fence specs flip: co-parent PATCH → 403/404; referent self-PATCH with changed email → REFERENT_EMAIL_READ_ONLY; same-value email → no-op success; merge eligibility: non-privileged caller never reaches merge (fence fires first), privileged merge unchanged (existing specs stay green); admin email change + collision 409 unchanged.
  • E2E specs (test/referents.e2e-spec.ts):
  • Flip the QA-batch co-parent describes: referent PATCH on unclaimed co-parent → 404 (or 403 per Q1 outcome); family merge attempt → 403 (email fence).
  • New pins: referent POST /referents → 403; referent DELETE /referents/:id → 403; referent self-PATCH contacts (phone) → 200; self-PATCH with changed email → 403 REFERENT_EMAIL_READ_ONLY; self-PATCH echoing current email → 200; admin PATCH email → 200 with invitation invalidation intact.
  • Manual verification: n/a — covered by e2e; no infra/seed surface. (Fabio runs all gates per standing instruction.)

12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-08-10
  • Chat reference: "go on with the plan" ack in chat 2026-08-10, adopting all four §10 recommendations (full read+write revert, dedicated 403 code, no-op email tolerance, admin-only merge)

Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.