Referent QA bug remediation — identity & linking, pickup defaults, justification consistency, admin visibility¶
Amendment 2026-08-11: W3's consistency matrix is unchanged. The separate event-covering rule is now exact (
ABSENCE → ABSENT,LATE_ENTRY → LATE_ENTRY), so a mismatched school-recorded state creates a new to-justify item without permitting a family submission to contradict an absence-covered day.
0. QA bug ledger → root causes → workstreams¶
| # | QA symptom (condensed) | Root cause (verified in code) | Workstream |
|---|---|---|---|
| 1a | Editing a student-side referent's name then email → "Elemento già esistente con lo stesso valore: Referente" | PATCH /referents/:id writes the email via a naked tx.referent.update (src/referents/referents.service.ts:347-352) — no lookup-and-branch, no P2002 handling. The @@unique([tenantId,email]) violation escapes to the generic P2002→CONFLICT mapper (src/common/filters/all-exceptions.filter.ts:203-219), rendering exactly the quoted string. Create-or-link semantics exist only on POST /referents (referents.service.ts:201-279). |
W1 |
| 1b | Editing "the other referent added email-only" → "referente non trovato" | REFERENT_NOT_FOUND thrown by the visibility fetch (referents.service.ts:310-322): ReferentsPolicy's referent branch narrows to { userId: ctx.userId } (referents.policy.ts:68-71) — a referent session can never reach the co-parent's row; even past the policy, assertCallerIsSelfOrAdmin (:324) 403s because the placeholder co-parent has userId = null. |
W1 |
| 1c | (hint) "same referent on 2 students by email, onboarding status inherited" | Already structurally true: Invitation is account-scoped @@unique([tenantId, recipientType, recipientId=Referent.id]) (prisma/schema.prisma:1340); POST /referents create-or-link works and is e2e-covered. The edit-email path (1a) is what's missing the affordance. |
W1 |
| 2 | Sole referent not authorized for pickup on EARLY_EXIT; self-editing the flag errors | StudentReferentLink.isAuthorizedPickup defaults false (schema.prisma:1276) and pickup eligibility hard-requires it (attendance-justifications.service.ts:453, query attendance-justifications.queries.ts:247-256). The flag lives under referents.students, READ-only in the referent preset (prisma/seed/roles.ts:142-147) — so the FE toggle exists but FieldWriteGuard rejects the write (by design: no self-authorization). |
W2 |
| 3 | Inconsistent/duplicate justifications all accepted | POST /attendance/family/justifications performs zero conflict checks: no read of attendanceJustification anywhere on the create path, unconditional insert (attendance-justifications.queries.ts:113-118), no unique index on the model (schema.prisma:902-903). All four QA cases insert cleanly. |
W3 |
| 4 | Referent filling co-parent's missing cellphone → "guardian not found" | Same code as 1b in the other locale: REFERENT_NOT_FOUND.en_US is literally "Guardian not found" (src/common/i18n/error-messages.catalog.ts:833-836) — a copy bug on top of the policy narrowing. Upstream cause of the "missing cellphone" itself: the import maps CSV referent_cell_phone_1/2 (labelled mobile phone) into homePhone (students.service.ts:2312-2337, :2713-2722) while completeness requires cellPhone (completion-required-fields.ts:60-71) — every imported referent is born permanently incomplete. |
W1 |
| 5a | Absence + late accepted for the same day | Same as #3. | W3 |
| 5b | Parent communications invisible on the admin register | Family justifications surface on exactly one staff route — the per-class drill-down GET /attendance/rows (attendance-board.service.ts:377-384). The school-wide day board is counts-only; no event row exists until the register is taken (mintForStudentDays only fires inside register writes); once taken, an announced day is born NOT_REQUIRED, which the office worklist excludes (attendance-insights.queries.ts:29-38). No admin route lists justifications at all (the file-download route GET /attendance/justifications/:id/files/:fileId requires an id the admin cannot discover). |
W4 |
| 6 | Meaning of the family attendance stats widgets unclear | Not a defect — copy/documentation gap. Semantics live in attendance-family.service.ts:312-377. Deliverable text in Appendix A. |
W5 |
1. Problem distillation¶
- W1 — Referent identity & linking: the referent record's "email is the identity key" doctrine is only half-implemented. Create paths do create-or-link; the edit path conflicts raw; a referent session cannot complete an unclaimed co-parent's profile; the
REFERENT_NOT_FOUNDEnglish copy says "Guardian"; the import feeds the wrong phone column so completeness flags every imported referent forever. (2026-08-10: the unclaimed-co-parent completion capability shipped here, and the referent-session leg of the merge path, were reverted by product decision — seedocs/superpowers/specs/2026-08-10-referent-self-only-writes-design.md; everything else in this workstream stands.) - W2 — Pickup authorization: product semantics are opt-out for referents (linked parents are presumed authorized to pick up their child; admin may revoke) and opt-in for guardians. Today both default to opt-in.
- W3 — Justification consistency: the family write path accepts contradictory and duplicate submissions; combined with the no-edit/no-delete doctrine, a wrong submission is unrecallable and display surfaces render contradictions side by side.
- W4 — Admin visibility: advance announcements and submitted justifications are invisible to the office outside one hard-to-reach drill-down; success and silence look identical.
- W5 — Stats copy: produce the authoritative explanation of the family overview counters (no code change).
Success criteria (observable):
1. PATCH /referents/:id changing an email onto an existing referent's email answers a structured 409 REFERENT_EMAIL_IN_USE; re-submitting with contacts.mergeIntoExisting: true merges the placeholder into the existing referent — links union, onboarding status inherited, placeholder row gone.
2. A referent session can PATCH an unclaimed (userId = null) co-parent linked to a shared child (identity + contacts blocks only) (reverted 2026-08-10 — see superseded_by; referent writes are self-only again); claimed co-parents stay self/admin-only. The error copy for REFERENT_NOT_FOUND reads "Referent not found" in English.
3. A student's CSV import row with referent_cell_phone_1 produces a referent whose cellPhone is set and whose completeness does not flag a missing cellphone.
4. A newly linked referent is immediately eligible as EARLY_EXIT pickup person; existing links are backfilled eligible; admin can still revoke per link. Guardian behavior unchanged.
5. The four QA inconsistency cases behave per the §5-W3 matrix: overlapping ABSENCE → 409; LATE_ENTRY/EARLY_EXIT on an absence-covered day → 409; same-kind same-day → in-place overwrite (same row id); ABSENCE over an existing single-day row supersedes it. absence + late same day can no longer coexist in either submission order.
6. An admin can list family justifications (GET /attendance/justifications) filtered by date/student/kind, without knowing a class or an id in advance.
Non-goals:
- No auto-writing of register cells from justifications — the cells-are-staff-authored, one-way doctrine stands (see Pushback).
- No referent self-service editing of link flags (canWrite, isAuthorizedPickup) — stays admin-only.
- No edit/delete API for justifications beyond the specified overwrite/supersede semantics.
- No day-board counter for announcements (deferred, see §9).
- No changes to guardians' opt-in pickup model.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/referents/referents.service.ts:201-279 (POST /referents create-or-link) + dto.students.replaceProfile opt-in flag |
The lookup-and-branch on (tenantId, email) and the explicit boolean opt-in inside a scope block precedent — mergeIntoExisting mirrors replaceProfile |
Create-or-link adds; the PATCH collision needs a merge (move links, retire the placeholder row) — new mechanics, same doctrine |
src/students/curriculum-selection.service.ts gate ordering (RUS-4, spec 2026-06-11-rus4-referent-selection-lock-design.md) |
Ordered service-level gates on a family write path (window → lock → applicable), each with a typed 409/422 | Justification gates are per-day matrix checks, not a single lock |
src/common/utils/assert-referent-can-write.ts + GuardiansWritePolicy (spec 2026-06-08-guardians-design.md) |
The established "referent writes are link-canWrite-gated, policy admits admin+referent" recipe — reused as-is for all W3 writes |
Fits cleanly |
src/referents/referents.service.ts:415-517 (remove: last-referent guard, invitation invalidation, token revoke, file cleanup) |
The full retire-a-referent checklist — the merge (W1) is a retire-with-heir and must walk the same list (invalidate invitation, move/delete files); documented TOCTOU acceptance (:405-408) reused for W3's probe-then-insert |
remove refuses to strand a student; merge never strands (links move to the heir) |
src/attendance/attendance-board.service.ts:377-384 (getRowBlocks justification enrichment) |
The existing per-row justification block loader + DTO — the W4 admin list reuses loadJustificationsForRows's shape and the family JustificationDto |
Board enrichment is cohort-anchored; the admin list is filter-anchored (date/student/kind) and paginated |
src/common/filters/all-exceptions.filter.ts:203-219 + src/common/i18n/error-messages.catalog.ts |
Typed ErrorCode + params + it/en messages + ERROR_EXAMPLES for every new 409 |
Fits cleanly |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Every new query filters tenantId; merge operates strictly inside one tenant ((tenantId,email) key); justification probes are (tenantId, studentId)-anchored |
RLS + service filtering, unchanged doctrine |
| Academic-year scope | partial | Justification conflict probe + admin list filter on the active year rows (academicYearId on AttendanceJustification); referents/links remain year-agnostic (global records) |
Matches existing model docs (schema.prisma:1214-1217) |
| RBAC entity key | existing | REFERENTS, STUDENTS, ATTENDANCE — no new entity |
Bugfix-shaped work |
| Scopes | existing | referents.identity/contacts (co-parent edit), referents.students (READ stays), attendance.register READ (admin list) |
No seed delta |
| Actions | none new | Family writes stay behind attendance.justify; admin list is read-implicit |
read is implicit — [[feedback_rbac_actions_convention]] |
| Service base | custom | All changes land in existing hand-rolled services (ReferentsService, AttendanceJustificationsService, AttendanceInsightsService) |
These modules never fit BaseTenantedCrudService (documented in referents.service.ts:381-414) |
queries.ts shape |
named fns | referents.queries.ts: findReferentByEmail (exists inline — extract), moveReferentLinks, moveReferentCollectionFiles; attendance-justifications.queries.ts: findJustificationsIntersecting(studentId, start, end), updateJustificationInPlace, deleteJustificationsByIds, restampEventProvenance, listJustificationsForAdmin |
Mandatory queries.ts convention |
| Error codes | 2 new + 1 copy fix | REFERENT_EMAIL_IN_USE (409, params { email }), JUSTIFICATION_CONFLICT (409, params { kind, conflictingKind, startDate, endDate }); fix REFERENT_NOT_FOUND.en_US "Guardian not found" → "Referent not found" |
Typed params + it/en + ERROR_EXAMPLES |
| DTO conventions | scope sub-DTOs | UpdateReferentContactsDto gains mergeIntoExisting?: boolean (inside the contacts block — top-level keys must be scope names for FieldWriteGuard); ReferentInputDto gains cellPhone (kept: homePhone); new AdminJustificationListQueryDto + AdminJustificationRowDto |
Precedent: students.replaceProfile lives inside its scope block |
| File-backed sub-resources | collection + single-slot | Merge moves the placeholder's collection File rows (ownerId → heir); single-slot files fill the heir's empty slot, else are deleted (reuse FilesService helpers from remove) |
No FileUsage delta |
| Custom fields | no | n/a — no entity read shape changes | |
| Profile completeness | no delta | cellPhone stays required; W1 makes it satisfiable (import mapping + backfill + co-parent editing) |
4. Data model plan¶
Schema deltas¶
StudentReferentLink.isAuthorizedPickup:@default(false)→@default(true)(column semantics flip from opt-in to opt-out). No other schema change — no new tables, no new columns anywhere in this batch.AttendanceJustification: intentionally no unique/exclusion constraint — range-overlap rules aren't expressible as a Prisma unique; consistency is a service-level gate inside the write transaction (TOCTOU accepted, same doctrine asreferents.service.ts:405-408; one family writes at human cadence).
Migration shape (one migration, three statements + audit)¶
- M1 (pickup default flip + backfill):
ALTER ... SET DEFAULT true+UPDATE student_referent_links SET is_authorized_pickup = true. Backfills all rows: under opt-in semantics existingfalsevalues are indistinguishable default noise, not decisions. - M2 (referent email normalization):
UPDATE referents SET email = lower(trim(email))— closes the case-variant duplicate hole (POST /referentsnormalizes,POST /students/import do not:referent-input.dto.tshas no transform). Pre-condition: audit forlower(email)collisions per tenant first (tools/one-off query); collisions must be hand-merged before the migration lands (expected zero — verify, don't assume). Migration fails loudly on the unique index if the audit was skipped — that is acceptable fail-closed behavior. - M3 (cellphone backfill):
UPDATE referents SET cell_phone = home_phone, home_phone = NULL WHERE cell_phone IS NULL AND home_phone IS NOT NULL. Provenance-justified:homePhonewas populated almost exclusively by the import's mobile-labelled columns. Accepted risk: a hand-entered genuine home phone migrates tocellPhone(recoverable by hand, self-corrects as families edit). - Hazards (ch12 checklist): M1/M3 are full-table
UPDATEs on small tables (fine); M2 can violatereferents_tenant_id_email_key— pre-audit is mandatory and the migration must be listed in the PR description as data-bearing. No RLS/tenanted-models delta (no new model).
Indexes and uniqueness¶
- None added. W3's probe (
studentId+ date-range intersect) is served by the existing@@index([tenantId, studentId, startDate]); the W4 list by@@index([tenantId, academicYearId, createdAt]).
5. API surface¶
W1 — referent identity & linking¶
| Verb | Path | Change |
|---|---|---|
| PATCH | /referents/:id |
Email-collision handling: when contacts.email (normalized) belongs to another referent in the tenant → 409 REFERENT_EMAIL_IN_USE (default). With contacts.mergeIntoExisting: true → merge (below). Decorators unchanged. |
| PATCH | /referents/:id |
Unclaimed co-parent editing: ReferentsPolicy referent branch widens to OR: [{ userId: ctx.userId }, { userId: null, students: { some: { student: { referents: { some: { canWrite: true, referent: { userId: ctx.userId } } } } } } }]. assertCallerIsSelfOrAdmin passes additionally when the session is a referent and the target row has userId = null (policy already proved shared-child linkage + caller canWrite). Service-level block limit: a co-parent (non-self) edit may carry identity + contacts only — documents/health/students blocks → 403 ACTION_NOT_PERMITTED. Teacher sessions gain nothing (their policy branch is read-reach; the self-or-admin assert still rejects them). |
| POST | /students (+ import) |
ReferentInputDto gains optional cellPhone (normalized email transform added, mirroring create-referent-contacts.dto.ts:17-19); the import row mapper writes referent_cell_phone_1/2 into cellPhone (was homePhone), matching to existing referents on the normalized email. |
| — | GET /referents/:id etc. |
Read reach for referent sessions widens with the policy (a referent can now GET the unclaimed co-parent directly — previously only via the student embed). Document routes keep their own self-or-admin gate → co-parent documents stay closed. |
Merge semantics (mergeIntoExisting: true, admin or eligible referent caller; runs in one transaction):
1. Source = the PATCHed row (placeholder), target = the tenant row owning the requested email. Refused (409 REFERENT_EMAIL_IN_USE, unchanged) when the source has userId != null — two claimed accounts never auto-merge.
2. Links: StudentReferentLink rows move source→target; a duplicate (studentId, targetId) pair keeps the target's existing link row (flags preserved), source's dropped.
3. Profile: patched + source field values apply to the target only where the target's field is null — an existing account's data is never clobbered (same doctrine as create-or-link).
4. Invitation: invalidateByRecipient(source, 'ROLE_DELETED'); the target's invitation row is untouched → onboarding status inherited (account-scoped by @@unique([tenantId, recipientType, recipientId])).
5. Files: collection files re-owned to target; single-slot files fill empty target slots else deleted.
6. Source row deleted. Response: the target referent's full ReferentResponseDto (the FE must adopt the returned id).
7. Audit: referent.merged with { sourceId, targetId, movedStudentIds } on the caller's tx.
W2 — pickup authorization¶
No API change. resolvePickupPerson keeps consulting StudentReferentLink.isAuthorizedPickup — with the default flipped and rows backfilled, every referent link is eligible unless an admin revokes it (PATCH /referents/:id link items, admin-only as today). Guardian branch untouched.
W3 — justification consistency matrix¶
Gate inserted in AttendanceJustificationsService.create inside the existing $transaction, after resolvePickupPerson, before insert: load findJustificationsIntersecting(tenantId, studentId, academicYearId, startDate..endDate) and apply:
| New submission ↓ vs existing → | ABSENCE (overlapping any day) | LATE_ENTRY (same day) | EARLY_EXIT (same day) |
|---|---|---|---|
| ABSENCE | 409 JUSTIFICATION_CONFLICT (extend by submitting the non-overlapping remainder) |
supersede: delete existing row (+ its files), re-stamp its event provenance to the new row | supersede (same) |
| LATE_ENTRY | 409 JUSTIFICATION_CONFLICT |
overwrite in place: update the existing row (time, reason, submittedBy*, updatedAt) — same row id, attachments kept |
allowed — coexists (late arrival + early exit is a legitimate day) |
| EARLY_EXIT | 409 JUSTIFICATION_CONFLICT |
allowed — coexists | overwrite in place (time, reason, pickup person re-resolved + re-snapshotted) |
- Overwrite keeps the row id ⇒ existing
AttendanceDayEvent.justificationIdprovenance stays valid with zero touch-up; the event sweep re-runs (idempotent — live states only). - Supersede deletes rows ⇒
justificationIdgoes null viaonDelete: SetNull; arestampEventProvenancepass re-points danglingACKNOWLEDGED(APP)/NOT_REQUIREDevents in range with covered types at the new absence row. - Responses: overwrite returns
201with the updatedJustificationDto(same id — FE refreshes its list); supersede returns the new row. No response-shape change. - Audit:
attendance_justification.replaced(overwrite,{ justificationId, kind, date }) andattendance_justification.createdgains optionalsupersededIdsin its data payload. - 409 carries
params: { kind, conflictingKind, startDate, endDate }of the blocking row so the FE can say what conflicts.
W4 — admin justifications list¶
| Verb | Path | Decorators | Request | Response |
|---|---|---|---|---|
| GET | /attendance/justifications |
Same gate as GET /attendance/follow-ups (AttendanceInsightsController): attendance.register READ + the office/admin policy — referent/student sessions excluded |
AdminJustificationListQueryDto: date? (a day the range must cover), studentId?, kind?, page/limit |
Paginated { data, meta }; row = family JustificationDto + studentId, studentName, attachment descriptors — the office sees exactly the block the family sees, plus the student identity |
Default window: the active school year, newest first. Sits beside the existing GET /attendance/justifications/:id/files/:fileId download (whose ids finally become discoverable). The per-class GET /attendance/rows enrichment already exists and is unchanged — the FE guide must tell the register UI to render students[].justifications[] (QA looked at a surface that had the data or a surface that never will; both ends get fixed: data was only on /rows, now also browsable here).
Swagger considerations¶
- New
ERROR_EXAMPLES:referentEmailInUse,justificationConflict; PATCH/referents/:iddocuments the 409 + merge flag; family justification POST documents the three 409 cases + overwrite semantics (public copy: "re-submitting the same kind for the same day replaces the previous communication"); new list route swagger entry inattendance.swagger.ts. REFERENT_NOT_FOUNDen copy fix is FE-visible copy — flagged as breaking in the new atomic FE guide (see §9).
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 — every change rides existing grants (co-parent editing is policy+assert widening, not a grant; admin list rides attendance.register READ) |
*_SCOPES runtime constant |
none |
No reseed required by this batch (first referent batch in a while with zero RBAC delta).
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
"Justifications are immutable after submit" (schema.prisma:860-869 doc-comment) |
Same-kind same-day overwrites in place; ABSENCE supersede-deletes single-day rows | QA's explicit "last wins" requirement; without it, the no-delete doctrine turns any mistake into a permanent contradiction (unrecallable by family and office) | The write path gains delete/update branches on a previously append-only table; audit trail carries the history instead of the table |
| "PATCH never re-links / referent rows are append-only identities" | mergeIntoExisting collapses a placeholder row into an existing identity |
The email IS the identity key; a placeholder with a mistyped email is a duplicate identity, not a person | Merge is opt-in-flagged, placeholder-only (userId = null source), so no silent identity collapse |
Referent self-service is strictly self-scoped (assertCallerIsSelfOrAdmin) |
Referent may edit an unclaimed co-parent's identity+contacts | The placeholder has no owner to edit it; admin-only completion contradicts the completeness nags shown to the family | Bounded: shared child + caller canWrite + target userId IS NULL + two blocks only; claiming the account instantly re-fences |
| One-way register doctrine (cells only staff-authored) | — (no divergence; explicitly retained) | QA asked for justifications to "reflect on the register"; we surface, never write cells | Announced days still render notTaken until the register is taken |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| Hint #2: "Referents AND GUARDIANS should automatically be authorized … while Guardians must explicitly be set" (self-contradictory) | Guardian opt-in model (guardians spec) | Follow the QA prose, not the hint's slip: referents default-authorized (revocable), guardians stay explicit opt-in | Resolved |
| Bug 2: "if I'm the sole referent I'm automatically authorized" | Sole-ness as a condition is fragile (linking a second referent would silently de-authorize the first?) | All referent links default-authorized regardless of count; admin revokes per link | Resolved |
| Bug 5: parent communications "non si riflette su registro" (expects them in the register) | Cells-are-staff-authored one-way doctrine | Surface justifications to the office (W4 list + existing /rows blocks + FE rendering) — never auto-write cells |
Resolved |
| Bug 3: "Uscita/Entrata in un giorno in cui c'è un assenza → impossible" — symmetric reading would also block ABSENCE over an existing entry/exit | No-delete doctrine: blocking that direction deadlocks the family (submit LATE_ENTRY for tomorrow, child turns out sick → could never justify the absence) | Asymmetric matrix: entry/exit onto absence = 409; absence onto entry/exit = supersede | Resolved |
| Bug 1 flow edits a referent to "add a student to a parent" | POST /referents is the canonical add/link API and already works |
Keep both: FE guide points the add-flow at POST /referents; the edit-flow gets the merge affordance so users who type the email into an edit form also succeed |
Resolved |
9. Deferrals¶
- Day-board announcement counter (e.g.
announcedalongside the seven counters onGET /attendance/groups) — W4's list covers the QA need; a counter is additive polish — follow-up: revisit with FE after the list ships. - Admin justification create/delete on behalf of a family — office corrections keep going through phone/in-person acks; a full office write surface is its own design — follow-up: future spec if QA asks.
- Advisory locking on the justification write path — probe-inside-tx TOCTOU accepted (single-family cadence); revisit only if duplicate rows ever appear in prod — follow-up: none.
- Case-collision auto-merge in M2 — migration demands a clean pre-audit instead of embedding merge logic in SQL — follow-up:
tools/audit query documented in the ops memory at execution time. - FE items (out of BE scope): hide the pickup toggle in referent view (permission-driven), route "add referent" to
POST /referents, adopt the merge response id, renderstudents[].justifications[]on the register drill-down, render the W3 409 params. Delivery vehicle (per Fabio, 2026-08-07): one NEW atomic FE guide file (docs/fe-guides/2026-08-07-referent-qa-bugs-FE-guide.md) containing everything — existing FE guides are NOT modified in place for this batch.
10. Open questions¶
None — all decision points resolved in-spec. Three call-outs to eyeball at sign-off because they touch data or doctrine: M2/M3 data migrations (§4), the merge semantics (§5-W1), and the overwrite/supersede divergence from justification immutability (§7).
11. Verification plan¶
- Unit specs
referents.service.spec.ts: email-collision →REFERENT_EMAIL_IN_USE; merge happy path (links union, null-only profile fill, invitation invalidation, file re-owning, source deleted, target returned); merge refused on claimed source; co-parent edit allowed (identity/contacts), rejected blocks (documents/health/students → 403), rejected on claimed target; teacher session still 403;REFERENT_NOT_FOUNDen copy assertion updated.attendance-justifications.service.spec.ts: full W3 matrix (9 cells — 3 blocks, 2 overwrites, 2 supersedes, 2 coexists); overwrite preserves row id + attachments; supersede deletes files + re-stamps provenance; pickup eligibility with default-true link; conflict probe scoped to the student + active year.attendance-insights(or new) spec: admin list filters (date-covering, studentId, kind), pagination, exclusion of referent sessions.- E2E specs
test/referents.e2e-spec.ts: PATCH email-collision 409 → merge flow end-to-end (invitation status inherited on the second student); referent-session co-parent completion (the QA bug-4 flow: fill missing cellphone → 200); claimed co-parent still hidden-404.test/students.e2e-spec.ts/ import e2e:referent_cell_phone_1lands incellPhone; completeness no longer flags it; case-variant email in the import links (not duplicates) after normalization.test/attendance-family.e2e-spec.ts(or current family suite): the QA bug-5 replay — absence then late same day → 409; late then absence → supersede; duplicate late → single row overwritten; EARLY_EXIT with an un-flagged (pre-backfill fixture) vs default link; admin list shows the submissions for that date.- Manual verification: replay the four QA scenarios verbatim on the seeded tenant (parent with two children, one placeholder co-parent). (User runs all gates and commits — per standing workflow.)
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-08-07
- Chat reference: "sign off, write the plan" — approved in chat 2026-08-07 after root-cause walkthrough (M2/M3 migrations, merge semantics, immutability divergence reviewed as flagged)
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.
Appendix A — Bug 6 deliverable: what the family attendance counters mean¶
(copy for the QA task / FE tooltips; source of truth attendance-family.service.ts:312-377, wire contract FE guide §4.2)
schoolDays— "Giorni di scuola": the number of days so far on which the child's register was actually taken (at least one recorded entry), excluding suspended/trip days. It is a running counter of lived, counted school days — not the year's planned total. It grows as teachers record registers.excludedDays { suspended, trip }— "esclusi N giorni": whole days deliberately left out of every count because the school marked them as lesson-suspension days or class-trip days. "Esclusi 1 giorno" = one such day exists; it counts neither as attended nor as absent.absent { days, percentage }: days (amongschoolDays) classified as full-day absences; the percentage isdays / schoolDays, one decimal.nulluntil at least one counted school day exists.lateEntry { days, percentage }: days with a late arrival, same denominator and null rule.presentHours { absentMinutes, totalMinutes, percentage }: minute-level view over recorded lessons only —totalMinutes= all recorded lesson minutes for the child,absentMinutes= the recorded minutes they missed,percentage= share of recorded time present. This is the number the school's minimum-attendance threshold speaks about.minPresentHoursPercentage: the school's (per-department) minimum-present-hours threshold to compare againstpresentHours.percentage.null= the school configured no threshold — hide the comparison.
Every percentage is null on an empty denominator (start of year) — render the absence of data, not 0%.