Skip to content

Office ack = full justification

1. Problem distillation

  • PATCH /attendance/follow-ups/:id/ack resolves the event (ACKNOWLEDGED + PHONE/IN_PERSON) but writes no AttendanceJustification row. 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 existing ack_channel pg enum; APP = family-submitted, PHONE/IN_PERSON = office-resolved. This is the marker product asked for, and write-time provenance (deriving it read-time from coveredEvents is 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. channel is 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

  • channel property 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 from FollowUpItemDto.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 emit channel (always APP) 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.
  • AttendanceContactLog writer — the abandoned round-2 direction; the model stays in the schema, dormant, read as lastContactAt where 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.
  • time on office LATE_ENTRY rows? → Inherited from the event's day: the earliest LATE_ENTRY cell's time; 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: AckChannel on the row + all three projections (product: visible to both admins and referents).

11. Verification plan

  • Unit specs:
  • attendance-loop.service.spec.ts — ack on an ABSENT event creates an ABSENCE row (single-day, channel, reason, actor attribution, academicYearId from the event) and stamps event.justificationId; ack on a LATE_ENTRY event creates a LATE_ENTRY row with time inherited from the earliest late-entry cell, and time: null when 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.service spec (wherever the projection is asserted) — baseProjection emits channel on family and admin mappers; getRowBlocks emits it on row blocks.
  • Queries spec — findLateEntryArrivalTime where-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 with channel = the ack's channel and canEdit: false, in the admin browse, and on the day's row block; the event leaves to-justify in the same round-trip; family PATCH/DELETE on 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.