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
AttendanceRecordrows at submit time, attributed to the family member who asserted it, and every existing surface (board counters,toRegister, rows, teacher-dayrecorded) 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: lockAyDayShared → resolveExpectedAttendance → per-anchor planning → per-student snapshot → upsert on attendance_cell_unique → audit.recordMany → loop.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.Uuid— soft FK, no relation, no cascade, matching the module's soft-FK rule (ch19 §2: onlytenant,academicYear,studentare hardRestrictFKs). Set only by the materializer;NULLon 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])onAttendanceRecord— the withdrawal predicate's access path. Without it, un-writing scans the tenant's register. attendance_cell_uniqueis untouched and remains the concurrency arbiter; the materializer'supsertconverges 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
AttendanceDayEventthe 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_EXITbecomes 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:
verdict.supersededIds/verdict.overwriteId→dematerializeForJustificationfor each affected old row (their cells go with them, §5.3).materializeForJustification(tx, persisted)for the new row.findCoveredLiveEvents+ theACKNOWLEDGED/APPsweep — 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 === null→ skip 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
isSchoolDayis 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:
dematerializeForJustification— hard-delete cells wherejustificationId = X AND lastModifiedByUserId IS NULL.mintForStudentDaysfor every touched(student, date)— the day is nownotTaken, so the ordinary diff resolves its event toCANCELLED(in grace) orSUPERSEDED.- 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_FLAGGEDneeds anerror-examples.tsentry and a row in the ch19 §13 error table.- Controller JSDoc on
POST /family/justificationsstates, 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/justificationsdegrades past ~1s on realistic ranges, move the per-date loop behindPostCommitCoordinatorand 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 (
justificationIdon 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
isMutablewindow; 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
justificationIdprovenance 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.