Skip to content

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_FOUND English 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 — see docs/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 as referents.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 existing false values are indistinguishable default noise, not decisions.
  • M2 (referent email normalization): UPDATE referents SET email = lower(trim(email)) — closes the case-variant duplicate hole (POST /referents normalizes, POST /students/import do not: referent-input.dto.ts has no transform). Pre-condition: audit for lower(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: homePhone was populated almost exclusively by the import's mobile-labelled columns. Accepted risk: a hand-entered genuine home phone migrates to cellPhone (recoverable by hand, self-corrects as families edit).
  • Hazards (ch12 checklist): M1/M3 are full-table UPDATEs on small tables (fine); M2 can violate referents_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: truemerge (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 onlydocuments/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.justificationId provenance stays valid with zero touch-up; the event sweep re-runs (idempotent — live states only).
  • Supersede deletes rows ⇒ justificationId goes null via onDelete: SetNull; a restampEventProvenance pass re-points dangling ACKNOWLEDGED(APP)/NOT_REQUIRED events in range with covered types at the new absence row.
  • Responses: overwrite returns 201 with the updated JustificationDto (same id — FE refreshes its list); supersede returns the new row. No response-shape change.
  • Audit: attendance_justification.replaced (overwrite, { justificationId, kind, date }) and attendance_justification.created gains optional supersededIds in 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/:id documents 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 in attendance.swagger.ts.
  • REFERENT_NOT_FOUND en 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. announced alongside the seven counters on GET /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, render students[].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_FOUND en 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_1 lands in cellPhone; 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 (among schoolDays) classified as full-day absences; the percentage is days / schoolDays, one decimal. null until 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 against presentHours.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%.