Office ack = full justification¶
1. Problem distillation¶
PATCH /attendance/follow-ups/:id/ackresolves the event (ACKNOWLEDGED+PHONE/IN_PERSON) but writes noAttendanceJustificationrow. An office-settled day is therefore invisible on every justification surface: the family list, the office browse, the row blocks nested on register and family day views — and the family modal shows the day in neither bucket.- Product ruling (chat 2026-08-10, third round — see §8): the ack counts as a justification, fully. No attachment needed. Reuse the current APIs — no new routes, no request-DTO change.
- It must surface as any other justification, with a marker saying it was resolved this way — visible to admins and referents.
Success criteria (observable behavior that proves this works):
- Acking a follow-up creates an AttendanceJustification row in the same transaction: kind mapped from the event type, single-day range on the event's date, channel = the ack's channel, reason carried over, arrival time inherited from the day's late-entry cell, submittedByUserId/Name = the office actor, and event.justificationId stamped.
- The row appears — with no further code per surface — on GET /attendance/family/justifications (canEdit: false), GET /attendance/justifications (office browse), and the nested row blocks, all via the shared baseProjection.
- Every justification DTO now carries channel: APP | PHONE | IN_PERSON; every pre-existing family row reads APP automatically (column default).
- The family modal's two buckets stay disjoint: the ack removes the event from to-justify and adds the row to justifications in one commit.
- No request-side change to any API.
Non-goals (in-scope-shaped things this iteration is explicitly not doing):
- No backfill. Confirmed 2026-08-10: no data exists with office acks.
- No cell materialization from ack rows. The office is looking at the actual register; the ack must not write cells on their behalf. (Events are past/today-dated anyway — materialization is a today+future concern.)
- No office edit/withdraw of justification rows, and no un-ack. A mistyped ack is permanent (see §9).
- No AttendanceContactLog writer. The round-2 product direction (contact-logging without resolution) was abandoned in round 3; the model stays dormant.
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 create() |
Row creation via createJustification(), audit verb attendance_justification.created, provenance stamp on the covered event |
The ack path needs no consistency matrix and no covering sweep: it targets exactly one live event, and a live event structurally proves no same-kind covering row exists (§7) |
docs/superpowers/specs/2026-08-10-referent-justification-crud-design.md + src/attendance/attendance-justifications.read.service.ts |
The unified projection (baseProjection → family/admin/row-block mappers) — one field added there surfaces everywhere; canEditJustification freezes past/today rows with zero new logic |
Fits cleanly — the strictly-future edit window makes office rows un-editable by the family for free |
docs/superpowers/specs/2026-08-10-attendance-justification-materialization-design.md |
The discipline around justificationId provenance ("write-time provenance — read-time derivation is ambiguous") — the ack becomes the third stamping seam after APP resolution and NOT_REQUIRED birth |
That spec writes cells; this one deliberately writes none |
src/attendance/attendance-justifications.queries.ts suggestedKindFor() |
The exact AttendanceEventType → AttendanceJustificationKind mapping (ABSENT → ABSENCE, else LATE_ENTRY), built for the FE's pre-fill suggestion |
Fits cleanly — now used server-side by the ack |
src/attendance/attendance-loop.service.ts ackFollowUp() |
The transaction being extended: 404/409 gates, actor resolution, event update, attendance_day_event.acknowledged audit row |
Fits cleanly — the row create slots in before the event update (the update needs row.id) |
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Row created with event.tenantId inside the existing tenant tx; attendance_justifications already has RLS coverage |
Existing table, existing policy |
| Academic-year scope | yes | academicYearId copied from the event row |
Same source the mint used |
| RBAC entity key | existing attendance |
No delta | Route and guards unchanged |
| Scopes | existing (register write on the ack route) |
No delta | Product: reuse current APIs |
| Actions | none new (manage_communications already gates the route) |
No delta | — |
| Service base | custom (existing AttendanceLoopService write, AttendanceJustificationsReadService read) |
Row create added to ackFollowUp's tx; channel added to baseProjection |
Established module shape |
queries.ts shape |
named functions | Reuse createJustification(); new findLateEntryArrivalTime(db, {tenantId, studentId, date}) — earliest (startTick asc) LATE_ENTRY cell's time, served by @@index([tenantId, studentId, date]) |
Queries convention: named functions only |
| Error codes | existing | 404 ATTENDANCE_EVENT_NOT_FOUND / 409 ATTENDANCE_EVENT_ALREADY_RESOLVED unchanged; no new codes |
The ack's failure modes don't change |
| DTO conventions | response-only delta | channel added to JustificationDto, AdminJustificationRowDto, RowJustificationDto; AckFollowUpDto untouched |
Additive on the wire; @AggregateResponse surfaces — the mapper emission IS the wire, so the field is added in baseProjection + the row-block builder, not just the DTO classes |
| File-backed sub-resources | n/a | Attachment routes untouched; a referent with canWrite may add evidence to an office row later (uploads are window-exempt — "evidence never freezes") |
No attachment is required on any row, office rows included |
| Custom fields | no | n/a | — |
| Profile completeness | no | n/a | — |
4. Data model plan¶
Schema deltas¶
AttendanceJustification.channel AckChannel @default(APP)— reuses the existingack_channelpg enum;APP= family-submitted,PHONE/IN_PERSON= office-resolved. This is the marker product asked for, and write-time provenance (deriving it read-time fromcoveredEventsis ambiguous — same argument as the materialization spec).
Migration shape¶
- Additive: one
NOT NULL ... DEFAULT 'APP'column — metadata-only on PG 11+, no table rewrite, no lock hazard. - Data backfill: none needed — the default is the backfill (every existing row is family-submitted).
- Fresh migration (decided 2026-08-10) — do NOT fold into the uncommitted
20260810134827_attendance_record_justification_id. - Hazards from chapter 12 checklist: none flagged. No new model ⇒ no RLS /
tenanted-models.ts/ drift-guard delta. No reseed.
Indexes and uniqueness¶
- None new.
channelis never a query axis — it's a display marker.
5. API surface¶
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| PATCH | /attendance/follow-ups/:id/ack |
unchanged | AckFollowUpDto (unchanged) |
FollowUpItemDto (unchanged — the row is a side effect; the refreshed worklist row already shows ACKNOWLEDGED) |
| GET | /attendance/family/justifications |
unchanged | — | JustificationDto + channel |
| GET | /attendance/justifications |
unchanged | — | AdminJustificationRowDto + channel |
| (nested) | register rows + family day view blocks | unchanged | — | RowJustificationDto + channel |
Swagger considerations¶
channelproperty description (FE-facing copy):APP= submitted by the family in the app;PHONE/IN_PERSON= the school resolved it directly with the family. FE already knows this enum fromFollowUpItemDto.ackChannel.- The ack route's JSDoc gains one sentence: acknowledging a follow-up records a full justification on the family's behalf.
POST /justifications(family create) responses also emitchannel(alwaysAPP) via the shared mappers — Swagger picks it up from the DTO class.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionScope (rbac-catalogue.ts) |
none |
PermissionAction (rbac-catalogue.ts) |
none |
ScopeFieldMapping (rbac-catalogue.ts) |
none |
| Role grants (roles.ts) | none |
*_SCOPES runtime constant |
none |
Zero RBAC delta ⇒ no reseed.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| Every justification row so far is family-authored | First rows authored by an office user (submittedByUserId = office actor; schema already allows it — nullable FK, name snapshot) |
Product: the ack IS the justification | submittedByName shows a staff name on a family-visible row; the channel marker is what makes that legible |
| Create path runs the consistency matrix + covering sweep | The ack path runs neither | Structural: a live event proves no same-kind covering row exists (submit-time sweep and mint-time pre-emption both resolve covered events, and a resolved event 409s the ack); the row is single-day on the event's date, so it can cover nothing else | Relies on the sweep/mint invariant — pinned by an e2e assertion (§11) |
Family LATE_ENTRY requires reason and accepts time from the caller |
Office LATE_ENTRY rows take reason as optional (the ack DTO's existing shape) and inherit time from the day's earliest LATE_ENTRY cell (null if the teacher left it empty) |
Product: reuse the current API unchanged; the register cell is the authoritative arrival time here (decided 2026-08-10) | A cell with no time yields a row with time: null — the DTO is already nullable |
| Later family writes treat every row alike | Office rows participate in the family matrix normally: a later overlapping same-kind family submission may merge/supersede (delete) an office row | Shielding them would fork the matrix for marginal benefit; the day stays justified either way — the marker flips to APP |
The office-acked event is untouched by that supersede: restampEventProvenance targets APP/NOT_REQUIRED only, so it keeps ACKNOWLEDGED/PHONE with justificationId nulled by the FK's SetNull; the withdrawal re-open cascade likewise never re-opens it |
Two refinements found during implementation (2026-08-10, after sign-off — both strengthen the design rather than contradict it):
| Refinement | Why the spec as written was not enough | What landed |
|---|---|---|
isMutable gained a channel leg: a row is family-mutable iff channel = APP and the date window is open |
§3/§7 claimed canEdit would be false on office rows "naturally, because events are past/today-dated". That is false in one reachable case: the asymmetric write window lets the office write a future register (ch19 §10 gate 1), which mints a future event, whose ack materializes a future-dated office row — mutable under the date rule alone. The family could then withdraw the school's own statement and put the settled day back to invisible, the exact state this iteration exists to remove. |
One && in attendance-justifications.shared.ts (the single definition, so the list flag, PATCH and DELETE all inherit it) + a unit case on each side and an e2e |
updateJustificationInPlace accepts channel, and create's submissionData sets channel: APP explicitly rather than relying on the column default |
§7 row 4 predicted the marker flip for the supersede case (a fresh row defaults to APP) but the overwrite-in-place branch reuses the existing row id — an office row overwritten by a family resubmission would have kept PHONE while carrying the family's substance and name |
channel added to the picked update field set (with the reason in its doc comment) and to submissionData |
8. Pushback log¶
| US says | Conflicts with | Proposed instead | Status |
|---|---|---|---|
| Round 1 (2026-08-10): "closing a follow-up should surface as a successful justification" | Round 2 (same day): "follow-ups are only a reminder to contact; closing ≠ justification, marker only" | Round 3 is final and reverts to round 1's shape: ack = full justification + marker | Resolved — round 3 adopted; the round-2 contact-log direction is a non-goal |
| This ruling | The 2026-08-10 justification-table FE guide's product call #1: "office-settled days appear in neither bucket of the modal" | Office-settled days now appear in the justified bucket, carrying the channel marker |
Resolved — both FE guides updated in place with a dated note (§11 / plan) |
| "no attachment needed if justified that way" | nothing — attachments were never required on any row | No change | Resolved — already true |
9. Deferrals¶
- Un-ack / correcting a wrong ack — no office edit or delete route exists, and the family can't touch a past-dated row either, so a wrong-channel or typo'd ack is permanent. Deliberate: same append-only posture the register takes. Revisit only if QA hits it.
AttendanceContactLogwriter — the abandoned round-2 direction; the model stays in the schema, dormant, read aslastContactAtwhere already wired. No follow-up planned.- Backfill of historical office acks — none; confirmed no data exists (2026-08-10).
10. Open questions¶
All resolved in chat 2026-08-10:
- Backfill? → No — no data.
-
timeon office LATE_ENTRY rows? → Inherited from the event's day: the earliestLATE_ENTRYcell'stime; the ack DTO gains nothing. - Fold the column into the uncommitted migration? → Fresh migration.
- Do office rows participate in the later-family-overlap matrix? → Yes, normally (recommended; not objected — see §7 row 4 for why the event survives a supersede).
- Marker shape? →
channel: AckChannelon the row + all three projections (product: visible to both admins and referents).
11. Verification plan¶
- Unit specs:
attendance-loop.service.spec.ts— ack on anABSENTevent creates anABSENCErow (single-day,channel,reason, actor attribution,academicYearIdfrom the event) and stampsevent.justificationId; ack on aLATE_ENTRYevent creates aLATE_ENTRYrow withtimeinherited from the earliest late-entry cell, andtime: nullwhen the cell has none; both audit rows written (attendance_day_event.acknowledged+attendance_justification.created); the 404/409 gates still write nothing.attendance-justifications.read.servicespec (wherever the projection is asserted) —baseProjectionemitschannelon family and admin mappers;getRowBlocksemits it on row blocks.- Queries spec —
findLateEntryArrivalTimewhere-shape:status: LATE_ENTRY,orderBy startTick asc,select { time }. - E2E specs:
test/attendance-justifications.e2e-spec.ts(owns both the justification helpers and the loop fixtures) — office acks a flagged absent day → the row appears in the family list withchannel= the ack's channel andcanEdit: false, in the admin browse, and on the day's row block; the event leavesto-justifyin the same round-trip; familyPATCH/DELETEon the office row answer the frozen-row error (strictly-future window). Plus the §7 invariant pin: submitting a covering family justification first makes the ack 409 (ALREADY_RESOLVED) — the structural reason the ack path needs no matrix. - Manual verification: n/a — e2e covers the loop end-to-end.
Gates (npm run build / npm test / npm run test:e2e) are run by Fabio, per standing convention.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-08-10
- Chat reference: "approved, write the plan" — chat 2026-08-10, after the three-round product ruling and the four decisions (no backfill / time inherited / fresh migration / matrix participation)
Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.