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
emailreadonly 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.createnor thereferents.deleteaction (REFERENT_ACTION_KEYSinprisma/seed/roles.tsis{attendance.justify, communications.send}), and the student-side write surface cannot add referents (UpdateStudentDtocarries noreferentsblock; 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 theReferentsPolicyreferent-branch OR-limb + the co-parent branch inassertUpdateWriteAllowed. - Family-session merge (same batch):
PATCH /referents/:id+contacts.mergeIntoExistinglets 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_USE409 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 block — SELF_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_emailuniqueness onReferentunchanged.
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. mergeIntoExistingfield JSDoc inUpdateReferentDto: 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_08memory on execution. /referents/mePATCH route (symmetry with/teachers/me,/staff/me) — not needed; referent self-edit keeps ridingPATCH /referents/:id+ policy. Revisit only if the FE asks for the/meergonomics.- Teacher/staff self-service email editability (
SELF_EDITABLE_SCOPESallows the wholecontactsblock, includinginstitutionalEmail, 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
ReferentsPolicyentirely; a referent resolves self only onGET /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.emailpasses silently; only a differing value rejects. - Q4 — Merge feature: kept, admin-only —
mergeIntoExistingsurvives 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);unclaimedCoParentWherespecs 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; referentDELETE /referents/:id→ 403; referent self-PATCH contacts (phone) → 200; self-PATCH with changed email → 403REFERENT_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.