Referent-side justification CRUD — browse, edit, withdraw¶
AMENDED 2026-08-10 by
2026-08-10-attendance-justification-materialization-design.md§5.4 — read that section before implementing the withdrawal cascade (Task 3 / Task 7 of this spec's plan).That spec makes an announced future day materialize into real
AttendanceRecordrows at submit time. Withdrawal must therefore walk back cells as well as events, and the order changes:
- hard-delete the cells this justification created and no human has touched (
justificationId = X AND lastModifiedByUserId IS NULL);mintForStudentDaysfor every touched(student, date)— the day is nownotTaken, so the ordinary diff resolves its event toCANCELLED/SUPERSEDED;- then this spec's
reopenForJustification, applied only to events the diff left standing.One assertion in this document inverts. §11's round-trip ("register written for a future day under an announced absence ⇒ event born
NOT_REQUIRED… DELETE ⇒ the event returns to to justify") holds only where the register was written by a human. Where the cells were materialized from the announcement itself, a withdrawal leaves the day with no register at all, so the event must cancel rather than reappear on to justify — the school never asserted an absence, and there is nothing to justify. The three cases are tabulated in that spec's §5.4.Everything else here — the strictly-future mutability rule, the matrix-with-
excludeIdedit path,canEdit, the attachment carve-out, thePHONE/IN_PERSONexemption — is unchanged.
1. Problem distillation¶
- Slice C shipped justifications as append-only:
POST+ list + attachments. A family that announces an absence for next week and then changes plans has no way to correct or cancel it. The only mutations that exist are side effects of a new submission (the consistency matrix's in-place overwrite and absence-supersede). - The family list is thin:
studentIdis its only filter, so a referent landing on the page cannot ask "what is coming up" without paging the whole year and filtering client-side. - A justification is not inert data. It resolves
AttendanceDayEventrows (ACKNOWLEDGED/APP) and pre-empts future ones at mint (NOT_REQUIRED). Any withdrawal or range change must walk that effect back, or the register keeps absences marked answered with nothing behind them — permanently outside both the office worklist and the family's to justify list. - The school's posture must stay asymmetric: a family may revise what it has announced, never rewrite what has already happened.
Success criteria (observable behavior that proves this works):
- A referent can list every justification of the active year across their linked students, narrowed by from (rows covering that day onward) and by studentId, and each row states whether they may still change it.
- A referent can PATCH and DELETE a justification whose first covered day has not yet arrived, and is refused 422 JUSTIFICATION_NOT_EDITABLE on one whose day has.
- Withdrawing (or shrinking the range of) a justification returns every event it had resolved or pre-empted to UNDER_REVIEW with a fresh grace window: the day reappears on the family's to justify list and on the office worklist, and the sweeper will chase it.
- An edit is refused with the same 409 JUSTIFICATION_CONFLICT a create would have raised, and supersedes the same neighbouring rows a create would have superseded.
- A referent can delete an attachment they uploaded, on any justification, regardless of its date.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No office-side edit or delete. GET /attendance/justifications stays read-only; the school corrects the register, not the family's statements.
- No approval/rejection workflow. A justification remains a dated statement, not a reviewable request — unchanged from slice C.
- No cross-academic-year history. The list stays pinned to the active year like every other attendance surface.
- No revision history on the row itself. The audit log is the trail; there is no AttendanceJustificationVersion table.
- No change to the create path's behaviour. POST semantics, including the absence-supersede rule, are untouched.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
docs/superpowers/specs/2026-08-04-attendance-justifications-design.md + src/attendance/attendance-justifications.service.ts |
The whole create path is the edit path's body: validateKindShape, resolvePickupPerson, enforceConsistency, the submit-time covering sweep, the post-commit blob queue. PATCH is a create that keeps its id. |
The create path has no notion of a prior state to walk back. Un-resolving events is new (§7 row 1), as is excluding self from the consistency probe. |
docs/superpowers/specs/2026-08-07-referent-qa-bugs-design.md §5-W3 |
The consistency matrix verbatim — same verdicts, same 409 JUSTIFICATION_CONFLICT params, same supersede-then-restamp mechanics. |
Its overwrite in place verdict is meaningless on an edit, which already has a fixed target row (§7 row 2). |
src/attendance/attendance-loop.service.ts mintForStudentDays |
reopenData — the exact field set that returns an event to UNDER_REVIEW with a fresh armAt and every timing/ack/provenance fact cleared. Already written for the re-open case; this spec gives it a second caller. |
Minting re-opens because the day changed shape. Here the day is unchanged and the statement was withdrawn — same field write, different trigger, so the method needs its own name and its own audit story. |
src/attendance/attendance.service.ts past-date lock (ATTENDANCE_PAST_DATE_LOCKED, 422) |
The idiom for a time-locked write: compare an ISO date against schoolTodayFor(tenantId) and refuse with a 422 naming the date. Same shape, same status, same school-clock source. |
The register's lock is role-conditional (school-wide writers pass). The justification lock is absolute — no family role escapes it, and no office role has the route at all. |
src/files/files.service.ts deleteFileById |
Ownership-aware collection-file delete: (ownerType, ownerId) must match, 404 covers both missing and wrong-owner, blob delete deferred past the real commit. Exactly what the attachment DELETE needs, already written. |
Fits cleanly — this is a pure new caller, no change to the file module. |
src/common/utils/assert-referent-can-write.ts referentLinkCanWrite |
The per-link canWrite fence, and its null-means-N/A convention. Gains a batched sibling for the list projection. |
Single-student only; a list page spans several children, so a per-row call would be an N+1. |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Every read and write carries tenantId; the row is resolved through findJustificationById(prisma, tenantId, id) and the event re-open's updateMany carries tenantId in its where. RLS covers attendance_justifications and attendance_day_events already. |
No new tenant-bearing model, so no rls-coverage.ts / tenanted-models.ts delta. |
| Academic-year scope | yes | resolveActiveYear on every entry point; the consistency probe filters academicYearId; the list keeps its active-year pin. The row's own academicYearId is never rewritten by a PATCH — an edit cannot move a row across years, because the strictly-future window plus the department-calendar check confine the effective range to the active year. A row belonging to a previous year needs no separate fence: its startDate is necessarily in the past, so the mutability gate refuses it first. |
Matches every other attendance surface; cross-year history is an explicit non-goal (§1). |
| RBAC entity key | existing ATTENDANCE |
No delta to src/common/constants/entity-keys.ts. |
The routes are more of the same entity. |
| Scopes | existing attendance.register read |
@RequireScopes(EntityKey.ATTENDANCE, 'read') on all three new routes, exactly as the existing family writes carry it. Deliberately not register:write — that is the teacher-write fence and drags Y-set semantics that do not apply here (ch19 §11.3). |
Zero scope delta. |
| Actions | existing attendance.justify |
@RequireAction(EntityKey.ATTENDANCE, 'justify') on PATCH, DELETE and the attachment DELETE — the same action that already gates POST and the attachment upload. Editing and withdrawing a justification is the same authority as making one. |
No new action key ⇒ no RBAC seed delta ⇒ no production reseed. A separate attendance.revoke_justification would buy nothing: no role should hold submit-without-withdraw. |
| Service base | custom — AttendanceJustificationsService |
Not a BaseTenantedCrudService entity (no scope-grouped field filtering; every route is @AggregateResponse()). New methods update, remove, removeAttachment land on the existing service beside create. |
Consistent with the module: nothing in src/attendance/ uses the CRUD base. |
queries.ts shape |
named functions, no repository class | attendance-justifications.queries.ts: findFamilyJustifications += from; findJustificationsIntersecting += excludeId; updateJustificationInPlace widened to accept startDate/endDate. attendance-loop.queries.ts: new reopenEventsStampedBy. |
Module convention (mandatory queries.ts, named functions only). |
| Error codes | one new | JUSTIFICATION_NOT_EDITABLE — 422, params { startDate }. Entries in error-codes.ts (enum + ErrorParams + required-params), error-examples.ts, and error-messages.catalog.ts (en_US / it_IT). |
A kind-change needs no code of its own: UpdateJustificationDto simply has no kind field, and the global pipe (whitelist + forbidNonWhitelisted) answers 400. The DTO is the contract. |
| DTO conventions | list-query DTO + partial-update DTO + one response field | dto/create-justification.dto.ts: FamilyJustificationsQueryDto += from?. New dto/update-justification.dto.ts: UpdateJustificationDto. dto/justification-response.dto.ts: JustificationDto += canEdit. RowJustificationDto is untouched — it is shared with office surfaces. |
ch05 foldering: one DTO concern per file. |
| File-backed sub-resources | collection (existing) | FileUsage.JUSTIFICATION + FileOwnerType.JUSTIFICATION, unchanged. The row DELETE reuses cleanupCollectionForOwner; the new attachment DELETE reuses deleteFileById. |
No FileUsage delta. |
| Custom fields | no | n/a — AttendanceJustification has no custom-field surface and gains none. |
|
| Profile completeness | no | n/a — no delta to completion-required-fields.ts; a justification is not part of any person's profile. |
4. Data model plan¶
Schema deltas¶
- None. No new tables, columns, FKs or enums.
Migration shape¶
- Additive / destructive / renaming: no migration at all.
- Data backfill: none.
- Hazards from chapter 12 checklist: none — chapter 12 is not engaged.
The existing shape already carries everything: AttendanceDayEvent.justificationId (nullable, onDelete: SetNull) is the provenance stamp the re-open walks backwards, and AttendanceJustification.updatedAt (@updatedAt) already records the edit instant.
Indexes and uniqueness¶
- No new indexes. The three reads this adds are already served:
- the
fromfilter and the consistency probe →@@index([tenantId, studentId, startDate]); - the list page →
@@index([tenantId, academicYearId, createdAt]); - the event re-open →
AttendanceDayEvent's existingjustificationIdFK index. - No new uniqueness. The consistency matrix stays a probe-then-write inside the transaction (TOCTOU accepted at family cadence — one family writes at human speed), unchanged from slice C.
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| GET (changed) | /attendance/family/justifications |
@RequireScopes(ATTENDANCE,'read'), @AppliesPolicy(AttendanceFamilyPolicy), @AggregateResponse() |
FamilyJustificationsQueryDto (studentId?, from?, page/limit) |
paginated JustificationDto (+ canEdit) |
| PATCH (new) | /attendance/family/justifications/:id |
@RequireScopes(ATTENDANCE,'read'), @RequireAction(ATTENDANCE,'justify'), @AppliesPolicy(AttendanceFamilyPolicy), @AggregateResponse() |
UpdateJustificationDto |
JustificationDto |
| DELETE (new) | /attendance/family/justifications/:id |
@RequireScopes(ATTENDANCE,'read'), @RequireAction(ATTENDANCE,'justify'), @AppliesPolicy(AttendanceFamilyPolicy), @HttpCode(NO_CONTENT) |
— | void (204) |
| DELETE (new) | /attendance/family/justifications/:id/files/:fileId |
@RequireScopes(ATTENDANCE,'read'), @RequireAction(ATTENDANCE,'justify'), @AppliesPolicy(AttendanceFamilyPolicy), @HttpCode(NO_CONTENT) |
— | void (204) |
FamilyJustificationsQueryDto — the from filter¶
/** Only communications still covering this day or a later one. */
@ApiPropertyOptional({ example: '2026-08-10' })
@IsOptional() @IsDateOnly()
from?: string;
Semantics: endDate >= from — covering from onward, not starting on it. A multi-day absence that began before from but has not finished is still upcoming from the family's point of view, and matches. This is the same "covering" reading the office list's date filter already uses. Omitted ⇒ the whole active year, unchanged from today.
UpdateJustificationDto¶
export class UpdateJustificationDto {
startDate?: string; // @IsDateOnly
endDate?: string; // @IsDateOnly — ABSENCE only
time?: string; // HH:mm — single-day kinds only
reason?: string; // @MaxLength(2000)
pickupPersonType?: PickupPersonType; // EARLY_EXIT only
pickupPersonId?: string; // @IsUUID — EARLY_EXIT only
}
No kind, no studentId. Both are frozen, and because the global pipe runs whitelist + forbidNonWhitelisted, a body carrying either is a plain 400 — the DTO enforces immutability without a domain error code. Changing kind or moving a communication to a sibling is delete-and-recreate.
Merge rule (normative). The patch is merged over the stored row into an effective submission, and that effective submission is validated by the same validateKindShape the create path uses. One shape validator, one set of rules. Two clarifications the merge needs:
- For the single-day kinds (
LATE_ENTRY,EARLY_EXIT) the storedendDateis dropped before validation and re-derived from the effectivestartDate. Otherwise movingstartDatealone would leave a stale one-day-wide range behind. - For
ABSENCE, an omittedendDatekeeps the stored one. If the newstartDateruns past it,validateKindShapefails the range and answers400— the caller must send both.
Audit¶
Two new action verbs, both recorded inside the caller's transaction beside the existing attendance_justification.created / .replaced:
| Action | data payload |
|---|---|
attendance_justification.updated |
studentId, kind, startDate, endDate, previousStartDate, previousEndDate, reopenedEventIds, resolvedEventIds, and supersededIds when an absence swallowed rows |
attendance_justification.deleted |
studentId, kind, startDate, endDate, reopenedEventIds |
Both need entries in src/audit-log/audit-event-labels.catalog.ts (en_US + it_IT). This is not optional decoration: assertCatalogCoverage runs at bootstrap, so an unlabelled action verb fails the app at startup rather than degrading a screen.
The attachment DELETE records no audit row, symmetric with the attachment upload, which records none either. Evidence attached to a statement is not itself a who-did-what fact; the statement is.
Swagger considerations¶
- Three new decorators in
attendance.swagger.ts(ApiUpdateJustification,ApiDeleteJustification,ApiDeleteJustificationAttachment), andApiListFamilyJustificationsupdated forfrom. - Error examples to register:
JUSTIFICATION_NOT_EDITABLE(422) on PATCH/DELETE;JUSTIFICATION_CONFLICT(409) on PATCH;JUSTIFICATION_NOT_FOUND(404) andFILE_NOT_FOUND(404) on the attachment DELETE. - Controller-method JSDoc is FE-facing OpenAPI copy: it states the mutability window and the withdrawal consequence in family language, and says nothing about transactions, sweeps or event states. The mechanics live in the service.
Documentation delta¶
Three prose surfaces assert append-only today and become false the moment this lands. Correcting them is part of the change, not follow-up:
docs/19-attendance.md§11.3 — the opening sentence ("No PATCH, no DELETE, no approval step") and the matrix's rationale paragraph ("with no delete route, blocking the absence direction would deadlock a family…"). The deadlock argument narrows but survives: delete only reaches strictly-future rows, so a family that announced a late arrival for today and whose child then fell ill all day still has the absence-supersede path as its only route. Say so, rather than deleting the paragraph.docs/19-attendance.md§13 — the route table (three new rows) and the error-code table (JUSTIFICATION_NOT_EDITABLE).- The
AttendanceJustificationsServiceclass doc-comment, which currently reads "There is no edit/delete route; the ONLY mutations are the consistency matrix's own". docs/REFERENCE.md— the attendance row in the §6 file index.
FE handoff updates the authoritative guide in place — docs/fe-guides/2026-08-05-attendance-family-FE-guide.md — rather than adding a new *-BREAKING.md file. Nothing here breaks an existing contract: from and canEdit are additive, and no existing response field changes shape.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none — reuses attendance.justify |
ScopeFieldMapping (rbac-catalogue.ts) |
none — every route is @AggregateResponse(), which bypasses scope-grouped field filtering |
| Role grants (roles.ts) | none — the referent preset already holds (attendance, justify) and (attendance.register, read) |
*_SCOPES runtime constant |
none |
No reseed. No prisma db seed in production. The entire iteration is service + controller + DTO + docs.
One consequence for the FE, called out because it is not derivable from /permissions: the edit window is a per-row temporal fact, not a grant. /permissions will report justify for every referent regardless of whether any given row is still mutable. That is what canEdit on the row is for.
canEdit — definition¶
canEdit is answered per row, for the calling session:
canEdit = isoDate(row.startDate) > schoolToday(tenant)
&& caller is a referent (ctx.roles includes 'referent')
&& caller's StudentReferentLink for row.studentId has canWrite = true
- Student sessions get
falseon every row. Students hold registerreadand reach the family list, but thejustifyaction is referent-preset only — they have never been able to submit and cannot edit. - The office list (
AdminJustificationRowDto extends JustificationDto) getsfalseon every row: no office edit route exists this iteration. The field reads "may you still change this", sofalseis accurate rather than a placeholder. - Computed with one batched query per page — new
referentWritableStudentIds(prisma, ctx, studentIds): Promise<Set<string>>alongside the existing single-studentreferentLinkCanWrite, same fence, same file. Never per row.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| Justifications are append-only (slice C: "No PATCH, no DELETE"; service doc-comment "There is no edit/delete route") | Adding both, bounded by a strictly-future window | The append-only posture was never a value judgement — it was slice C deferring the school's stance on justifications. The family's own announcements about days that have not happened need no school stance. | The service and chapter doc-comments become wrong on the day this lands and must be rewritten in the same change, not left as stale narration. |
The consistency matrix's overwrite in place verdict |
On the PATCH path only, a same-kind row intersecting the effective range (other than self) is a 409 JUSTIFICATION_CONFLICT instead of a merge |
On create, "overwrite in place" collapses a resubmission into the existing row and returns that row — sound, because the caller had no row of their own. On an edit the caller already owns a row: merging would have to either write into the other row and delete this one (the response's id changes under the FE) or delete the other and keep this one (a silent destruction of a same-kind announcement). Neither is defensible; refusing is. |
Two verdict tables to document for one matrix — mitigated by stating the difference as a single sentence: an edit refuses what a create would have absorbed into itself, and supersedes what a create would have superseded. |
onDelete: SetNull as the deletion story for AttendanceDayEvent.justificationId |
Explicitly re-opening the events before the row is deleted, rather than letting the FK null itself out | SetNull preserves referential integrity but not domain truth: the event would keep ACKNOWLEDGED/NOT_REQUIRED while the statement that justified it no longer exists — outside the open worklist, outside to justify, invisible to everyone forever. |
The delete path must run its re-open first; ordering is load-bearing and gets its own unit test. Documented in the service as a hazard comment, since the FK's own semantics invite the opposite assumption. |
The loop's reopenData is minting-private |
Lifting it into a shared builder in attendance-loop.queries.ts and giving AttendanceLoopService a second public entry point, reopenForJustification |
The field set (state, armAt, three timing fields, the five ack fields, justificationId) is one fact — "this episode starts over". Duplicating it in the justifications service would let the two copies drift, and the drift would be silent. |
AttendanceJustificationsService gains a dependency on AttendanceLoopService. No cycle: the loop service depends on attendance-justifications.queries.ts (a query module), never on the justifications service. |
| The "past is read-only" rule | Attachments are exempt: upload and delete both work on any justification regardless of its date | Upload is already window-free and shipped that way, for a real reason — a medical certificate arrives after the child returns, i.e. always onto a past row. Freezing only deletion would leave a family able to attach a wrong file to a past absence forever and never remove it. | The rule the FE must state is two-part: the statement freezes when its first day arrives, its evidence never does. Written into the FE guide explicitly rather than left to inference. |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| "see ALL justification ever submitted" | Every attendance read is pinned to the active academic year via resolveActiveYear; dropping the pin here would make this the only year-spanning attendance surface and would need academicYearId on the DTO. |
Active year only. "All" reads as "the whole year, not just what is open". Revisit when year rollover ships — no tenant has a second year of justification data today. | Resolved — confirmed in chat 2026-08-10 |
| "A in-the-future justification can be edited/removed at any time" — "future" left undefined | Needs a precise boundary against the school clock, and a multi-day absence spanning today is genuinely ambiguous (part past, part future). | Strictly future: startDate > schoolToday. A row freezes entirely the moment its first covered day arrives — one uniform rule for PATCH and DELETE, no partial-range editing anywhere. |
Resolved — confirmed in chat 2026-08-10 |
| "consistency must be enforced […] should be already in place" | It is in place on the create path, but enforceConsistency has no way to exclude the row being edited: on PATCH it would find the row itself intersecting and return a verdict to overwrite itself. |
Add excludeId to the probe and reuse the matrix unchanged otherwise. The premise is right; the reuse needs one parameter. |
Resolved — no product decision needed |
| Implied by "removed": that deletion is a simple row delete | A justification owns downstream event state. A bare delete manufactures silently-answered absences. | Deletion is a withdrawal: re-open every event this row resolved (ACKNOWLEDGED/APP) or pre-empted (NOT_REQUIRED), then delete. Office acks (PHONE/IN_PERSON) are never touched — they name a human decision, not this row. |
Resolved — confirmed in chat 2026-08-10 |
9. Deferrals¶
- Office-side edit/delete of justifications — the school's posture toward a family statement is still unmodelled (slice C deferred it; this iteration deliberately does not decide it). Follow-up: revisit if the office asks to correct a family's typo.
- Cross-year justification history — needs
academicYearIdon the DTO and a year filter, and is only meaningful after rollover. Follow-up: fold into the year-rollover work (project_ay_archival_parked). - Multi-child (
studentIds[]) filtering — deliberate YAGNI. Families have a handful of children and a single-select child filter is the natural control; the UNION-chips convention (project_table_filters_union_chips) exists for admin tables over hundreds of rows. Follow-up: widenstudentId→ repeatablestudentIdsif the FE reports a real multi-select need. Widening later is backward-compatible; narrowing is not. from/torange filtering on the office list — this iteration touches the family list only. The office list keeps its single covering-datefilter. Follow-up: next attendance QA batch.- Notifying the school when a family withdraws — the re-open puts the day back on the office worklist, which is the existing channel. No dedicated notification. Follow-up: revisit if the office reports being surprised by reappearing cases.
- Revision history on the row — the audit log carries
attendance_justification.updatedwith before/after ranges; there is no versions table and no family-facing "edited on" marker beyondupdatedAt. Follow-up: only if a dispute case appears.
10. Open questions¶
None — all resolved in chat 2026-08-10 (mutability boundary, event cascade, history scope, editable field set, attachment window, edit-path conflict handling).
11. Verification plan¶
Unit specs — src/attendance/attendance-justifications.service.spec.ts¶
Mutability gate
- startDate strictly after school-today ⇒ PATCH and DELETE proceed.
- startDate equal to school-today ⇒ both refuse 422 JUSTIFICATION_NOT_EDITABLE with params.startDate; no transaction opens.
- startDate before school-today, endDate after ⇒ still refused (the whole row freezes; the range spanning today is not a carve-out).
- A PATCH whose effective startDate lands on or before school-today ⇒ refused, even though the stored row was mutable (no back-dating).
Field contract
- kind in the body ⇒ rejected by the pipe (asserted at e2e level, where the pipe runs).
- Single-day kind, startDate moved, endDate omitted ⇒ effective endDate follows the new startDate.
- ABSENCE, startDate moved past the stored endDate, endDate omitted ⇒ 400 VALIDATION_FAILED.
- Effective range outside the department calendar ⇒ 400 VALIDATION_FAILED.
- EARLY_EXIT pickup person re-resolved and re-snapshotted on edit; a no-longer-authorized person ⇒ 422 PICKUP_PERSON_NOT_ELIGIBLE.
Consistency on the edit path
- The row being edited is excluded from the probe (a no-op PATCH does not conflict with itself).
- Intersecting ABSENCE ⇒ 409 JUSTIFICATION_CONFLICT naming the blocker.
- ABSENCE widened over another kind's single-day row ⇒ that row and its attachments are deleted and the events re-stamped, exactly as on create.
- Widened onto a same-kind row ⇒ 409, not a merge (the §7 divergence).
Event cascade
- PATCH shrinking the range re-opens only the events on days that left coverage; days still covered keep their ACKNOWLEDGED state and are not churned.
- PATCH widening the range resolves newly covered live events to ACKNOWLEDGED/APP.
- DELETE re-opens every event stamped with the row.
- Re-open runs before the row delete (assert call order — after the delete the stamp is null and the events are unreachable).
- Audit rows: attendance_justification.updated carries previous and new range plus reopenedEventIds; attendance_justification.deleted carries the range plus reopenedEventIds.
Attachments
- Attachment DELETE succeeds on a past (frozen) justification — the window does not apply.
- Attachment DELETE on someone else's justification ⇒ hidden 404.
- Row DELETE cleans up the collection and defers blob deletes to the post-commit coordinator.
canEdit projection
- True only for future rows on students the caller may write; one batched link query per page, not one per row.
- False on every row for a student session and for the office list.
Unit specs — src/attendance/attendance-loop.service.spec.ts¶
reopenForJustificationtouchesNOT_REQUIREDandACKNOWLEDGED-with-ackChannel: APPonly.- It never touches
ACKNOWLEDGEDwithPHONE/IN_PERSON(an office decision survives a family withdrawal), norCANCELLED/SUPERSEDED. - The re-opened row lands
UNDER_REVIEWwitharmAt = now + graceand every timing, ack and provenance field cleared. - With
outsideRangesupplied, rows inside the range are left alone.
E2E — test/attendance-justifications.e2e-spec.ts (primary home — it already owns the create path and the matrix)¶
- Lifecycle: submit an absence for a future week → appears in the list with
canEdit: true→ PATCH shortens it → DELETE removes it → gone from the list. - Freeze: a justification covering today refuses PATCH and DELETE with
422, and lists withcanEdit: false. - Filters:
frommatches a multi-day absence that started before it but has not ended;studentIdnarrows; an unlinkedstudentIdreturns an empty page rather than a 403. - Fences: a student session gets
403on PATCH/DELETE; a referent withcanWrite: falsegets403; another family's justification id is a404on every route. - Whitelist: a body carrying
kindis a400. - Attachments: delete succeeds on a frozen past justification; a foreign
fileIdis a404.
E2E — test/attendance-loop.e2e-spec.ts (the cascade — the event state machine and the sweeper already have fixtures here)¶
- Withdrawal round-trip (the one that proves the cascade end to end): register written for a future day under an announced absence ⇒ event born
NOT_REQUIREDand absent from the family's to justify list; DELETE the justification ⇒ the event returns to to justify once its fresh grace elapses, lands on the office follow-up worklist, andsweep()then notifies. - Office ack survives: an event resolved by a
PHONEack is untouched when an overlapping justification is withdrawn.
E2E temporal discipline per feedback_e2e_isolation_patterns and project_e2e_temporal_fixture_discipline: dates are computed relative to the seeded school clock, never hard-coded, and the sweeper is driven by invoking sweep() directly (the interval is 0 under NODE_ENV=test).
Manual verification¶
Log in as a seeded referent, announce an absence for next Monday–Wednesday, confirm it appears with an edit affordance; shorten it to Monday–Tuesday; confirm Wednesday's day view no longer shows the announcement; delete it; confirm the announcement is gone from every day view and the office justifications list.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-08-10
- Chat reference: approved by Fabio in chat 2026-08-10 ("approved"), after the six design decisions were taken by explicit choice in the same session — mutability boundary (strictly future), event cascade (re-open), history scope (active year), editable field set (kind frozen), attachment window (exempt) and edit-path conflict handling (same matrix, self excluded).
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.