Skip to content

Justification materialization — announced days arrive as real register cells

1. Problem distillation

  • A family announces an absence for a future day. The office or the teacher lands on that day's register and sees an empty grid — the announcement is returned as justifications[] next to the row, but nothing connects it to the cells. Every announced day is still taken by hand.
  • The register is a legal compliance document about what happened. That objection has no force for a day that has not happened yet: ch19 §10 gate 1 already rules the future open to every writer — "a planned trip or a known absence is registrable in advance, with the audit trail carrying the responsibility." An announced absence is precisely that.
  • So the fix is not a read-time hint. It is a write: the announcement materializes into ordinary AttendanceRecord rows at submit time, attributed to the family member who asserted it, and every existing surface (board counters, toRegister, rows, teacher-day recorded) renders them for free.
  • The symmetric obligation follows: a withdrawn or superseded announcement must un-write what it wrote. The module has no cell-deletion path today; this spec adds the first one, scoped to cells the materializer itself created and no human has touched.
  • Doing this exposes that one route has always done two different jobs, and that the difference — not the age of the date — is the rule the domain actually has. Opening a communication about a day nothing has happened on yet is an announcement; answering a day the school has already flagged is a justification. Announcements are today-or-later by nature. Justifications are answers, and a flagged day stays answerable forever — the school controls what is on the list, so there is nothing to bound.

Success criteria (observable behavior that proves this works): - A referent submits an ABSENCE for a future school day; an admin opening GET /attendance/rows for that date sees every one of that student's slots as source: 'RECORDED', status ABSENT, recordedByName = the submitting parent, and record.justificationId naming the announcement. - The same submission for today, before the register is taken, produces the same cells. If a teacher already recorded that tick, the existing cell is left untouched. - The day's AttendanceDayEvent is born NOT_REQUIRED with armAt = NULL; sweep() never claims it and no notification is sent (slice A's no-notify invariant, unchanged). - Withdrawing that justification (referent-CRUD spec) deletes the untouched auto-cells; the day returns to notTaken and its event resolves to CANCELLED/SUPERSEDED through the ordinary mint diff — it does not reappear on the family's to-justify list, because nothing was ever asserted about that day. - A submission reaching into the past is accepted iff a past day in its range carries a family-visible event of a type its kind covers, and writes no cells. One reaching into a past day the school never flagged is refused 422 JUSTIFICATION_DAY_NOT_FLAGGED. No deadline, ever: a flagged day is answerable for as long as it stays unresolved.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No read-time "suggestion" layer. The earlier proposal (a non-persisted items[].suggestion) is dropped: materialization makes it redundant for today/future, and the past deliberately gets nothing. - No fabricated statuses. An announced late arrival writes the LATE_ENTRY cell and nothing else — never PRESENT for the rest of the day (§4.2). - No family override of an authored cell, ever, in any direction (§4.3). - No change to who may submit a justification, no new scope, no new action, no reseed. - No backfill of existing justifications into cells (§4.6).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/attendance/attendance.service.ts writeRecords (ch19 §10) The whole write spine: lockAyDaySharedresolveExpectedAttendance → per-anchor planning → per-student snapshot → upsert on attendance_cell_uniqueaudit.recordManyloop.mintForStudentDays. Reused verbatim, not re-implemented. It is anchor-driven from caller-supplied entries and cohort-fenced. The materializer has no cohort and no entries: it derives both from the resolved day for one student. Gates 0 (cohort), 1 (past-lock) and 3 (group fence) are caller-shaped and do not apply to a system write.
src/attendance/attendance-loop.service.ts mintForStudentDays + findCoveringJustification (ch19 §11.1, §11.3) The covering seam is already exactly what we need: cells land, mint runs in the same tx, the just-created justification covers the day, the event is born NOT_REQUIRED with armAt = NULL. Zero new coordination — the no-notify invariant holds by construction. Mint derives from the register and is deliberately opinion-free about why cells exist. It cannot tell a family-materialized cell from a teacher-authored one — which is why §4.4 needs an explicit provenance column rather than an inference.
src/attendance/attendance-justifications.service.ts create + enforceConsistency (ch19 §11.3) The host transaction, audit.resolveActor for the submitter's name snapshot, the supersede/overwrite verdict, and the AfterCommitQueue + PostCommitCoordinator pairing for blob cleanup. Materialization slots in after the verdict and before the audit row. Today the create tx is cheap and bounded. Materialization makes its cost proportional to the announced range (§4.7) — the first family-facing request in this module whose work scales with user input.
docs/superpowers/specs/2026-08-10-referent-justification-crud-design.md (approved, unimplemented) Its withdrawal cascade, its strictly-future mutability window, and its canEdit per-row temporal flag. This spec's un-write hooks into that cascade rather than inventing a parallel one. Its cascade walks back events only. With materialization, cells exist too, and the order it specifies is now wrong on its own terms — §5.4 amends it.
src/timetables/timetables.service.ts valid-time writes (ch18 §5.0) The "system writes rows a user did not enumerate" posture, and per-date deterministic lock ordering to keep concurrent writers from deadlocking. Timetables writes are admin-triggered and synchronous with one date; this fans across a range.

3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes Every read and write goes through the ambient tx inside the justification's own $transaction; AttendanceRecord is already RLS-covered and in tenanted-models.ts. The new column adds no new model, so no drift-guard delta. ch12 "new tenant-bearing model checklist" does not fire — this is a column on an existing covered model.
Academic-year scope yes academicYearId is resolved once by the justification's create (resolveActiveYear) and threaded into every materialized cell, matching writeRecords. The range is already fenced to the department's school year by validateKindShape (attendance-justifications.service.ts:632-634). A cell must carry the same AY as its justification, or the year's reads disagree with each other.
RBAC entity key existing EntityKey.ATTENDANCE No delta. Nothing new is being protected.
Scopes existing register No delta. The family path keeps register READ + the justify action; the cell write is system-attributed inside that transaction, not an exercise of register WRITE. Granting referents register WRITE would drag Y-set semantics into the family surface — explicitly rejected by slice C (ch19 §11.3 "Gating"). Recorded as divergence D1.
Actions none new justify already gates submission; withdrawal reuses it per the approved CRUD spec. ch19 §8: "no role should hold submit-without-withdraw".
Service base custom — new AttendanceMaterializationService in src/attendance/ Holds materializeForJustification(tx, …) and dematerializeForJustification(tx, …). Injected into AttendanceJustificationsService. It calls into a new exported internal on AttendanceService (or a shared attendance-cell-writer.ts) that owns the upsert+audit+mint spine writeRecords already uses. A third copy of the write spine is the failure mode to avoid. One writer, two entry points: caller-driven (writeRecords) and system-driven (this).
queries.ts shape additive, in attendance-justifications.queries.ts findMaterializedCells(tx, {tenantId, justificationId}), deleteMaterializedCells(tx, {tenantId, justificationId}) (both filtered on lastModifiedByUserId: null). Named functions, no repository class. Matches the module convention.
Error codes 1 new JUSTIFICATION_DAY_NOT_FLAGGED → 422, params: { startDate }. Entries in error-codes.ts, error-messages.catalog.ts (en/it), error-examples.ts, and the ch19 §13 table. A family-facing refusal needs its own copy — reusing VALIDATION_FAILED would render "invalid request" for a rule the parent can act on ("that day isn't one we've recorded an absence for").
DTO conventions additive, non-breaking justificationId: string \| null on AttendanceItemRecordDto and AttendanceRegisterCellDto (dto/group-rows-response.dto.ts). Gives the FE its "announced by the family" badge — the visible half of the original ask. No deadline field: there is no deadline (§5.1).
File-backed sub-resources n/a — no file surface changes Attachment cleanup on supersede already exists and is untouched.
Custom fields no Attendance carries none.
Profile completeness no Unrelated surface.

4. Data model plan

4.1 Schema deltas

  • AttendanceRecord.justificationId String? @map("justification_id") @db.Uuidsoft FK, no relation, no cascade, matching the module's soft-FK rule (ch19 §2: only tenant, academicYear, student are hard Restrict FKs). Set only by the materializer; NULL on every human-authored cell.
  • No new enum values, no new model, no RLS policy delta.

4.2 The mapping — write only what was asserted

Resolved per student per date from resolveExpectedAttendance; attendanceMode comes from the student's own department snapshot, exactly as activity cells resolve it (ch19 "Activities").

Kind PERIOD department DAILY department
ABSENCE every slot of the student's day → ABSENT one ABSENT cell at the day's first slot; projection covers the rest
LATE_ENTRY the arrival slot → LATE_ENTRY + time identical — one cell
EARLY_EXIT the exit slot → EARLY_EXIT + time identical — one cell

Never fabricate. An announced late arrival says nothing about whether the student stays all day, so the slots after it are left untaken for the teacher. This is also what keeps the day legal: §6's machine never flags a day's first authored cell, so a lone LATE_ENTRY at 10:00 or a lone EARLY_EXIT at 13:00 is contradiction-free, and the teacher's own cells slot in around it.

Transition-slot resolution. The slot whose [startMinute, startMinute + durationMinutes) contains time; else the first slot starting after time; if time precedes every slot, clamp to the first slot; if it follows every slot, write nothing (there is no slot to anchor to). ABSENCE needs no such resolution.

4.3 The overwrite rule — fill empty ticks only

The materializer never overwrites an existing cell, in any mode, for any kind. If (student, date, startTick) already holds a row, that tick is skipped and the rest of the day proceeds.

This is the load-bearing safety rule. A parent submitting at 09:10 must not be able to flip a PRESENT the teacher recorded at 08:00 — that would let the family author the compliance record over a witness. Skipping also makes the whole operation idempotent and re-runnable, and it is what makes "today" safe to include at all.

Consequence, accepted: a family announcement submitted after the register is taken has no cell effect. That is correct — the register already says what happened, and the justification's job there is the covering seam (event → ACKNOWLEDGED), which is unchanged.

4.4 Provenance — why a column and not an inference

justificationId on the cell is what makes un-writing possible and honest. Deleting by "matching status and date" would delete cells a teacher authored that merely agree with the announcement.

The withdrawal predicate is therefore exact: justificationId = X AND lastModifiedByUserId IS NULL. A cell a human has since edited has been adopted — the human's assertion outlives the announcement and is left standing.

The same column is the FE's badge (record.justificationId non-null ⇒ "announced by the family"), correlating with the row's existing justifications[] block.

4.5 Attribution

recordedByUserId = ctx.userId (the submitting referent — the column is already nullable, schema.prisma:652), recordedByName = actor.name from the same audit.resolveActor the justification row uses. The register then truthfully reads "asserted by the family", which is strictly more honest than a teacher clicking through a prefill and landing their own name in the provenance column.

Audit: one row per materialized cell, verb attendance_record.family_announced, data: { status, time, note: null, justificationId }. New label entry in audit-event-labels.catalog.ts (en/it) and the filter enum in dto/list-audit-log-query.dto.ts. Deletion emits attendance_record.withdrawn with { justificationId }.

4.6 Migration shape

  • Additive: one nullable column. No backfill — existing justifications are not retroactively materialized (they cover days that are now past or already registered; materializing them would author cells into a taken register, which §4.3 forbids anyway).
  • Hazards from ch12 checklist: none flagged. Nullable column, no default, no type change, no index rename, no enum edit.

4.7 Indexes and uniqueness

  • New index @@index([tenantId, justificationId]) on AttendanceRecord — the withdrawal predicate's access path. Without it, un-writing scans the tenant's register.
  • attendance_cell_unique is untouched and remains the concurrency arbiter; the materializer's upsert converges against a concurrent teacher write exactly as two teachers do today.

5. API surface

No new routes. Two existing ones change behaviour, and one gains a field.

Verb Path Decorators Request DTO Response DTO
POST /attendance/family/justifications unchanged: @RequireScopes(ATTENDANCE,'read') + @RequireAction(ATTENDANCE,'justify') + AttendanceFamilyPolicy CreateJustificationDto (unchanged) JustificationDto (unchanged)
DELETE /attendance/family/justifications/:id per the approved CRUD spec per that spec
GET /attendance/family/to-justify unchanged PaginationQueryDto unchanged
GET /attendance/rows, POST/PATCH /records unchanged unchanged justificationId on AttendanceItemRecordDto / AttendanceRegisterCellDto

5.1 Two jobs, one route — opening vs answering

POST /family/justifications has always done two different things, and the code has never distinguished them:

  • Opening — "my child will be absent next Tuesday." Unprompted, about a day nothing has happened on, no event exists.
  • Answering — "my child was absent yesterday, here's why." A reply to an AttendanceDayEvent the school minted when a teacher took the register.

The rule is that distinction, not the age of the date:

Range reaches Rule
today or later free — this is an announcement, and it materializes (§4.2)
into the past accepted iff a past day in the range carries a family-visible event of a type this kind covers; writes no cells

A flagged day is answerable forever. There is no deadline and no config knob. The school decides what lands on the family's list by taking the register; while a day sits there unresolved, the family can answer it — a year later if that is when the certificate arrives. The office worklist already keeps open events indefinitely, so this adds no accumulation. If a school ever wants a deadline, it is an additive knob on this same gate.

The gate reuses two existing primitives. familyVisibleEventWhere(now) (attendance-family.queries.ts) is already exactly "the school surfaced this day to you" — state ∈ {NOTIFIED, FOLLOW_UP, ACKNOWLEDGED, NOT_REQUIRED} or armed UNDER_REVIEW, excluding in-grace and CANCELLED/SUPERSEDED. coveredEventTypesFor(kind) already maps the kind to the event types it answers. The check is one indexed query on [tenantId, studentId, date], and only for the past portion of a range; today-or-later skips it entirely.

At least one, not all. A multi-day ABSENCE reaching back needs one flagged past day, not a flagged day for each. A Mon–Fri illness where the teacher took the register Mon–Wed and not Thu–Fri must not be refused. This is an anti-fabrication check, not a completeness check.

Two consequences that follow forcibly, both correct:

  • EARLY_EXIT becomes announcement-only. No event type is ever minted for an early exit (ch19 §11.3 — "EARLY_EXIT covers nothing"), so it can never be an answer, so it is today-or-later by construction. Right on the merits too: "I am collecting my child at 13:00" is inherently forward-looking, and filing one for last Tuesday asserts nothing the register does not already hold.
  • A past day the school never flagged is unjustifiable — register never taken, or the day classified present. There is nothing to justify. If the office later back-fills that register, the event mints and the family can answer then.

One rough edge, accepted. remove() gates withdrawal on isMutable (startDate > schoolToday, strictly future), so a communication announced for today materializes cells and then freezes — the family cannot withdraw it, and the office must correct the register instead. That is the existing mutability ruling ("a family may revise what it has announced, never rewrite what has already happened"), and a day that has begun is happening. Not re-opened here.

This also makes the materialization story one rule instead of two that happen to share a boundary: announcements materialize, answers do not.

5.2 Materialization placement in create

Inside the existing $transaction, after enforceConsistency + persist and before audit.record:

  1. verdict.supersededIds / verdict.overwriteIddematerializeForJustification for each affected old row (their cells go with them, §5.3).
  2. materializeForJustification(tx, persisted) for the new row.
  3. findCoveredLiveEvents + the ACKNOWLEDGED/APP sweep — unchanged, and now runs after mint has minted anything the new cells warrant.

Ordering matters: mint (inside the materializer, per date) births covered events NOT_REQUIRED; the submit-time sweep then finds nothing left open for those days, which is correct and idempotent.

5.3 Per-date loop

For each date in [startDate, endDate], ascending (deterministic lock order — two concurrent submissions overlapping on a range must take lockAyDayShared in the same sequence or they deadlock):

  • lockAyDayShared(tx, tenantId, ayId, isoDate)
  • resolveExpectedAttendance(...); timetableId === nullskip this date (no 409 — an announcement must never fail on a day the school has no schedule for)
  • student has no slots that day, or isSchoolDay is false → skip
  • map per §4.2, skip occupied ticks per §4.3, upsert, audit, mintForStudentDays

Skips are silent and never fail the submission. A range that materializes nothing still persists the justification and answers 201 — behaviourally identical to today.

Range cap. At most MATERIALIZE_MAX_DAYS = 30 school days are materialized inline; a longer announced absence persists in full and materializes its first 30 days. Beyond that the existing mint-time covering seam still applies, so long absences degrade to exactly today's behaviour rather than to something broken. A named constant, not config.

5.4 Amendment to the approved referent-CRUD spec

2026-08-10-referent-justification-crud-design.md is approved and unimplemented. Its withdrawal cascade walks back events; with materialization it must walk back cells first, and its stated order becomes wrong on its own terms. The amended sequence for DELETE:

  1. dematerializeForJustification — hard-delete cells where justificationId = X AND lastModifiedByUserId IS NULL.
  2. mintForStudentDays for every touched (student, date) — the day is now notTaken, so the ordinary diff resolves its event to CANCELLED (in grace) or SUPERSEDED.
  3. Then that spec's explicit re-open, applied only to events the diff left standing.

The result is right in both directions without a special case, and the distinction falls out of the data:

Withdrawn justification What happens
Future/today, cells materialized and untouched cells deleted → day notTaken → event cancelled. Not re-opened: the school never asserted an absence, so there is nothing to justify.
An answer to a flagged past day, no cells written no cells to delete → the teacher's register still says ABSENT → that spec's re-open applies verbatim, day returns to to-justify.
Materialized but a teacher has since edited a cell that cell survives (§4.4) → mint sees a real register → event re-opens. The human's assertion wins.

That spec's §11 round-trip test ("register written for a future day under an announced absence ⇒ event born NOT_REQUIRED … DELETE ⇒ the event returns to to-justify") inverts under this amendment for the auto-written case and must be rewritten to the table above.

update is the same cascade with a rewrite on the end, and it needs one ruling of its own: dematerialization there is total, not outsideRange-shaped. An edit may move time, which moves the transition slot, so a day that stayed inside the range can still need its cell at a different tick — a selective delete strands the old one. Order inside that transaction: persist → dematerialize(all) → reopenForJustification(outsideRange) → materialize(new range) → the covered-events sweep.

The interaction between those two steps is self-correcting and must not be "fixed": for a day that left the range holding only auto-cells, dematerialization's own mint cancels its event, and reopenEventsStampedBy admits only NOT_REQUIRED and ACKNOWLEDGED+APP — so a CANCELLED event is not resurrected, which is exactly right (the day left the range and the school never asserted anything about it). For a day that left the range holding a teacher-authored register there are no auto-cells to delete, so the re-open applies verbatim. Both outcomes fall out of predicates that already exist.

Swagger considerations

  • JUSTIFICATION_DAY_NOT_FLAGGED needs an error-examples.ts entry and a row in the ch19 §13 error table.
  • Controller JSDoc on POST /family/justifications states, in family language, that announcing a future day pre-fills the register, that withdrawing it removes those entries, and that a past day can be justified whenever the school has recorded an absence for it. No mention of transactions, mint, or cell provenance — that is backend internals (CLAUDE.md Swagger rule).

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none — all touched routes are @AggregateResponse() (ch19 §13)
Role grants (roles.ts) none
*_SCOPES runtime constant none

No reseed required. The one durable consequence worth stating: this is the first place a cell is written without any caller holding attendance.register:WRITE.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
D1 — "register WRITE is the teacher/office write fence" (ch19 §11.3) A family transaction produces register rows The write is system-attributed and derived from an assertion the family is already authorized to make; granting referents register WRITE would import Y-set semantics into a surface where they are meaningless The fence now reads "no caller writes cells without register WRITE" rather than "no cells appear from a family request". §4.3 (never overwrite) is what keeps that weakening safe
D2 — snapshot columns are written once and never re-synced (ch19 §14) Materialized cells can be deleted, which no cell could be before Withdrawal must not leave the register asserting an absence nobody claims First deletion path in the module. Bounded hard by justificationId IS NOT NULL AND lastModifiedByUserId IS NULL; the audit trail retains the full history of the deleted rows
D3 — freezeDay fires when a human takes a register Freezes now fire routinely for future days Materialization is a write, and the freeze is the write path's invariant Harmless: the manifest is write-path evidence only and nothing reads it (ch19 §3.1). A revision landing before the day arrives leaves a stale unread manifest, exactly as a manual future write does today
D4 — family requests are cheap and bounded Submit cost is proportional to the announced range The user chose inline over post-commit A 30-day cap bounds the worst case (§5.3); a materialization failure rolls the whole submission back rather than half-writing
D5 — coverage is applied at exactly two event-driven seams, never at read time (ch19 §11.3) Untouched: this is a write-time seam, not a third read-time derivation The rule exists because overlapping justifications make read-time coverage ambiguous; a stored justificationId on the cell is provenance, not a re-derivation None — the invariant is preserved, and §4.4 strengthens it by recording rather than inferring

8. Pushback log

US says Conflicts with Proposed instead Status
(chat) "see prefilled cells from justifications" A read-time hint would need the FE to own the kind→status mapping, which must agree with assertValidStatusFields, the §6 inconsistency machine and the DAILY/PERIOD projection — three BE-owned rule sets Materialize real cells; drop the suggestion layer entirely Resolved — chat 2026-08-10
(chat) "register is legal, but it makes 0 sense with dates in the future, so it's correct to auto write" My initial objection that auto-write violates the compliance-record principle Conceded and confirmed by ch19 §10 gate 1, which already permits registering a known absence in advance Resolved — the objection held only for the past, which §4.3/§5.1 now fence off
(chat) "past submission should not be possible at all" GET /family/to-justify returns events for days that already happened; the sweeper notifies "please justify"; the only in-app answer is a past-dated submission. A hard block dead-ends the loop and points a notification at a form the family cannot submit First proposed a bounded date window. Rejected by the user as the wrong axis: the real distinction is opening vs answering, not how old the day is. Final rule = §5.1 — free for today+, past allowed only against a flagged day, no deadline Resolved — user's own framing, 2026-08-10
(my draft) a configurable grace window bounding how far back a family may file A day the school has flagged and never resolved does not stop mattering when a timer expires — in real terms a certificate arrives when it arrives, and the office worklist already keeps the case open indefinitely. A deadline invents an expiry the domain does not have, and buys a config knob, an error code, a DTO field and a calendar-vs-school-days argument to approximate a rule the event list already encodes Deleted entirely. A flagged day is answerable forever Resolved — corrected by the user 2026-08-10
Implied by "prefill": that the family may fill a tick a teacher already recorded The register's witness principle Never overwrite; skip occupied ticks (§4.3) Resolved — stated as a design rule, not an option

9. Deferrals

  • Post-commit / queued materialization — user chose inline. Follow-up: if p95 on POST /family/justifications degrades past ~1s on realistic ranges, move the per-date loop behind PostCommitCoordinator and accept the (already tolerated) crash window. Revisit at next iteration.
  • Materializing beyond 30 days — long absences fall back to the existing mint-time covering seam. Follow-up: revisit if schools report multi-month medical absences as a real flow.
  • Retro-materialization of pre-existing justifications — no backfill (§4.6).
  • A "family-announced" filter on the admin justification list / board — the data now exists (justificationId on the cell); no surface consumes it beyond the row badge. Follow-up: next QA batch if the office asks.
  • Any deadline on answering a flagged day — deliberately none (§5.1). If a school ever asks for one, it is an additive bound on the same gate, not a redesign.
  • Withdrawing a communication announced for today — blocked by the existing isMutable window; the office corrects the register instead (§5.1 "one rough edge"). Follow-up: revisit only if families report it.

10. Open questions

  • Today, or strictly future? → today too, guarded by never-overwrite (§4.3). Resolved in chat 2026-08-10.
  • Past submissions? → allowed only as answers to days the school flagged, no cells written, no deadline. The grace window I first proposed was deleted as the wrong axis. Resolved in chat 2026-08-10.
  • Withdrawal semantics on cells? → hard delete, bounded to untouched auto-cells. Resolved in chat 2026-08-10.
  • Cost posture? → inline in the submit transaction. Resolved in chat 2026-08-10.

None open.


11. Verification plan

Unit specs - attendance-materialization.service.spec.ts (new) — the §4.2 mapping table in both modes and all three kinds; transition-slot resolution including both clamp edges; §4.3 skip-occupied; per-date skips (no timetable / not a school day / no slots); ascending date order; the 30-day cap; attribution columns. - attendance-justifications.service.spec.ts (extend) — the window gate (inside / outside / N=0); materialization called after the consistency verdict and before the audit row; supersede and in-place-overwrite both dematerialize the rows they displace; a materialization throw rolls the submission back. - attendance-board.service.spec.ts (extend) — justificationId surfaces on both items[].record and register[]. - attendance-loop.service.spec.ts (extend) — an event minted from materialized cells is born NOT_REQUIRED with armAt = NULL; after dematerialization the day's diff cancels it. - attendance-notifier.service.spec.ts (extend) — sweep() never claims an event covered by materialized cells (the no-notify invariant, end to end).

E2E specs - test/attendance-justifications.e2e-spec.ts — the headline round trip: referent announces a future absence → admin GET /attendance/rows on that date shows RECORDED/ABSENT cells carrying justificationId and the parent's name → referent withdraws → the cells are gone and the day is notTaken. - Same file — today-before-register (cells appear) vs today-after-register (existing cell untouched, no duplicate). - Same file — the window: a submission N+1 days back → 422 JUSTIFICATION_WINDOW_CLOSED; one N-1 days back → 201 with no cells. - test/attendance-loop.e2e-spec.ts — announce future absence, run sweep(), assert no notification and no inbox row. - Teacher-adoption case: materialized cell edited via PATCH /records/:id, then withdrawal → cell survives, event re-opens.

Per feedback_e2e_isolation_patterns.md: per-worker DB clones, no cross-suite fixture reuse, and the temporal fixtures follow project_e2e_temporal_fixture_discipline rules — future dates must be anchored relative to the seeded school year, never hard-coded.

Manual verification: submit an ABSENCE for tomorrow as a referent; open the admin day register for tomorrow; confirm the grid renders filled with the parent's name in provenance; withdraw; confirm the grid empties.


12. Sign-off

  • Approved by: Fabio Barbieri
  • Date: 2026-08-10
  • Chat reference: design decisions taken in chat 2026-08-10 (today+future, hard delete, inline), then "approved, write the plan" on the filled spec, which also accepted never-overwrite, the justificationId provenance column, the 30-day materialization cap and the §5.4 amendment to the referent-CRUD spec.
  • Revised 2026-08-10, same day, post-sign-off: the grace-window rule (§5.1) was deleted and replaced by the opening-vs-answering split, on the user's ruling that a flagged day is answerable forever. Affects §1, §3, §5, §8, §9, §10 and plan Tasks 1/7/8/9/10/11. No change to materialization itself.

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