Disciplinary notes — student-centric CRUD, armed family notification, and acknowledgement¶
1. Problem distillation¶
- Add a student-centric disciplinary-note register. A note contains only a student, an occurrence date, and non-blank free text;
context, category, severity, title, and attachments are outside v1. - Give school-side callers one paginated list. An optional attendance-style cohort filter narrows the list to a current class/course, while omission returns the caller's whole policy-visible note set.
- Give teachers all notes of students in the repository's canonical current-day taught set. Give school-wide management roles every row, department heads their assigned departments, curriculum coordinators their assigned curricula, and referents delivered notes for currently linked children. Students have no access.
- Provide full CRUD before family acknowledgement. Teachers may mutate only notes they authored; management callers may mutate any visible note. The first referent acknowledgement is shared and permanently locks update/delete.
- Arm initial and revised notifications with the existing attendance grace and sweep cadence. Referents cannot see or acknowledge the current revision until it is delivered; there are no reminders or follow-ups.
- Keep notification-center read state separate from the domain acknowledgement. Notification/email payloads contain identifiers and stable display data, never the disciplinary text.
Success criteria (observable behavior that proves this works):
- An authorized school-side caller creates { studentId, date, text }; the note is immediately visible to staff, is acked: false, and is hidden from referents until armAt is claimed.
- At the configured attendance arm deadline, every currently linked referent with an active account receives one discipline.note inbox/email notification and the note becomes family-visible. Zero reachable accounts still advances the note to delivered.
- The first currently linked referent to call the explicit ack endpoint sets the note's one shared acknowledgement. A second ack is idempotent; notification readAt never acknowledges the note.
- A delivered but unacknowledged edit re-arms the note, temporarily hides it from referents, and emits a fresh UPDATED notification after the new grace. Repeated edits before delivery move the deadline and produce only one send for the settled revision.
- An acknowledged note rejects PATCH and DELETE. An unacknowledged DELETE withdraws the note, cancels pending delivery, hides it from lists, and makes an authorized old notification link resolve as typed 410 Gone without sending a withdrawal notification.
- Omitting the cohort filter returns every note allowed by the caller's record policy; providing it only narrows that set. Reads accept optional academicYearId and default it to the active year; writes always target the active year.
- Create/update/withdraw/ack are audited atomically, but no per-note history endpoint exists in v1.
Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No context field, note category/severity/title, attachments, follow-up queue, reminder, escalation, justification, comments, or family reply. - No per-referent acknowledgement or recipient-delivery rows; one referent ack closes the note for everyone. - No student-role access and no acknowledgement by staff on a family's behalf. - No notes-specific class/course board, grouped counts, or unacknowledged counters. Existing class/course catalogues provide navigation; the notes API only filters. - No public delivery state, delivery timestamp, acknowledgement actor/timestamp, author user id/role, or note-history endpoint. - No hard delete of the database row and no withdrawal notification in v1. - No custom fields and no import/export/bulk-create surface.
2. Patterns survey¶
| Analogous module/spec | What we'd borrow | What doesn't fit |
|---|---|---|
src/grades/ and docs/21-grades.md |
Student-centric, teacher-authored/family-visible records; read-policy vs write-authority split; author snapshots; active-year writes; audit events | A disciplinary note has no subject-group anchor or grading aggregate, has notification/ack lifecycle, and reads use the full canonical taught-student set rather than grade-entry visibility |
src/attendance/attendance-notifier.service.ts and 2026-08-04-attendance-loop-notifications-design.md |
Armed visibility, per-tenant sweeper, full-predicate CAS claim, live linked-referent resolution, zero-recipient claim, claim-commit-before-send, at-most-once delivery | Notes have one delivery per revision, no reminder/follow-up states, and remain explicitly editable/withdrawable until acknowledged |
src/students/students.policy.ts and 2026-08-02-teacher-people-visibility-narrowing-design.md |
studentTaughtByCallerOn exactly as shipped: direct SG episode ∪ combined-class sibling ∪ tutored homeroom ∪ supervised-activity audience; dated DEPARTMENT/CURRICULUM parametric branches |
Notes filter through the hard student relation and additionally apply delivery/withdrawal visibility by caller |
src/notifications/ and docs/23-notifications.md |
Existing discipline.note placeholder, typed lean payload, inbox + email broadcast, post-claim send, never-throw engine, live User-id addressing |
The placeholder must become typed and gain an email spec; all scheduling remains in the disciplinary-notes consumer, not the engine |
src/audit-log/ and docs/20-audit-log.md |
Resolve actor once per transaction and append one full-snapshot event per logical mutation | No dedicated /:id/history projection in v1; sweeper delivery transitions are machine evidence and not audit events |
docs/05-crud-patterns.md §4 and §7.1 |
One cross-role entity endpoint, typed paginated filters, optional AY override, visibility AND filter composition | Custom service rather than BaseTenantedCrudService: lifecycle locking, soft withdrawal, current-roster cohort filters, audit, and re-arming dominate the write paths |
On-axis / off-axis check (chapter 16 §4–§5): the entity, RBAC catalogue, policy, DTO, queries, audit, and notification-consumer work are paved patterns. The off-axis seam is the platform's second scheduled background writer and deliberate reuse of attendance-named timing configuration. V1 keeps a separate domain-owned disciplinary-note sweeper using the proven per-tenant/CAS template; it does not move scheduling into the notification engine or introduce a generic job framework.
3. Architecture mapping¶
| Primitive | Apply? | How | Justify |
|---|---|---|---|
| Tenant scope | yes | Every row carries required tenantId; every query includes it; add FORCE RLS policy and both coverage registries |
Contains family-visible sensitive text |
| Academic-year scope | yes | Row carries required academicYearId; GET list accepts optional override/default active; POST/PATCH/DELETE/ack target the row but all creation/date validation is against the active year |
Standard read/write split; current-day authorization remains independent of the selected read year |
| RBAC entity key | new DISCIPLINARY_NOTES = 'disciplinary_notes' |
Add to entity-keys.ts, entity groups, scope registry/drift mirrors, labels, and docs module map |
Independent record and action model |
| Scopes | record |
One zero-field-mapping descriptor/gate scope; register the entity in FLAT_DTO_ENTITIES |
Every authorized caller receives the same flat DTO; no field-level split exists |
| Actions | create, delete, acknowledge |
create/delete require record:WRITE; acknowledge requires record:READ. Add acknowledge to ACTION_NAMES |
UPDATE is scope WRITE; explicit ack is distinct from generic notification read and from CRUD |
| Service base | custom | DisciplinaryNotesService; explicit transactions, actor audit, lifecycle transitions, ownership checks, date/placement validation, and computed capabilities |
Base CRUD would obscure the state machine and soft-delete semantics |
queries.ts shape |
yes | List/detail includes, list-args builder, current-roster cohort predicate, student visibility predicate composition, due scan, CAS claim, recipient projection | Keeps Prisma plumbing and top-level OR composition single-sourced |
| Error codes | new + existing | New codes listed in §5; reuse VALIDATION_FAILED and hidden NOT_FOUND where appropriate |
Stable FE behavior for lifecycle conflicts and withdrawn links |
| DTO conventions | flat DTO + typed list query | CreateDisciplinaryNoteDto, UpdateDisciplinaryNoteDto, ListDisciplinaryNotesQueryDto, DisciplinaryNoteDto; flat entity bypasses scope grouping |
Same response fields for staff/referent; computed fields are server-owned |
| File-backed sub-resources | n/a | No attachments in v1 | Explicit deferral |
| Custom fields | no | No customFields, others scope, or custom-field definitions |
Text is the complete v1 content model |
| Profile completeness | no | No person-profile required-field delta | Notes do not affect onboarding completeness |
Record policy (normative)¶
DisciplinaryNotesPolicy is a definePolicy<Prisma.DisciplinaryNoteWhereInput> whose buildBase is { tenantId }. Services derive schoolToday once and pass { ...ctx, schoolToday } before calling where(ctx).
| Caller | Row contribution |
|---|---|
platform admin / admin / secretary / principal |
Tenant-wide pass-through |
department_head or any DEPARTMENT-parametric role with the grants |
student.placements SOME effectiveOn(schoolToday) AND departmentId IN assigned ids |
curriculum_coordinator or any CURRICULUM-parametric role with the grants |
student.curriculumSelections SOME effectiveOn(schoolToday) AND curriculumId IN assigned ids |
teacher |
student: studentTaughtByCallerOn(ctx, schoolToday) verbatim, including all four canonical legs |
referent |
student.referents SOME referent.userId = caller, plus family-state visibility below |
student, hr, staff, unmatched role |
No contribution; fail closed |
Multiple contributions retain definePolicy's OR semantics. Authorship is never a visibility limb: once a teacher stops teaching the student on the current school day, they lose the note even if they created it. The same current-day rule applies while reading an older academicYearId: the year filters note rows, while today's relationship controls who may see them.
Family-state visibility: referents may query only DELIVERED and ACKNOWLEDGED rows. PENDING is hidden from list/detail/ack. WITHDRAWN is absent from lists; direct detail returns 410 only when the row had previously been delivered and the caller is currently linked, otherwise it remains hidden as 404.
Mutation authority (normative)¶
- Create: every role granted
disciplinary_notes.create, bounded to a student admitted by the same current-day student predicate as its note rows. - Update/withdraw:
admin,secretary,principal,department_head, andcurriculum_coordinatormay mutate any visible unacknowledged note. Ateachermay mutate only a visible unacknowledged note whoserecordedByUserIdequals the caller. A teacher's visibility loss removes mutation access too. - Acknowledgement: only the
referentpreset holdsdisciplinary_notes.acknowledge; the service additionally requires a currentStudentReferentLink.canWriteis deliberately ignored. - Student id is immutable. Correcting the student is withdraw + create.
Lifecycle (normative)¶
Internal state is not exposed directly:
POST
└─ PENDING (armAt = now + shared grace; hidden from family)
├─ PATCH ───────────────→ PENDING (revision++, fresh armAt)
├─ DELETE ──────────────→ WITHDRAWN
└─ due CAS claim ───────→ DELIVERED (visible + ackable; send)
├─ PATCH ─→ PENDING (revision++, fresh armAt)
├─ DELETE → WITHDRAWN
└─ ACK ───→ ACKNOWLEDGED (terminal)
ACKNOWLEDGED: PATCH/DELETE rejected
WITHDRAWN: hidden from lists; authorized old link gets 410; no ack/reopen
ackedis computed asstate === ACKNOWLEDGED.- Every successful PATCH of
dateand/ortextmoves an unacknowledged note toPENDING, clears current-revision delivery evidence, incrementsrevision, and resetsarmAtfrom edit time. Several edits before a claim produce only the final revision's delivery. firstNotifiedAtis retained across edits. The first successful claim emits payload change typeCREATED; a later successful claim emitsUPDATED.- ACK is explicit, shared, and idempotent. The first caller stamps
acknowledgedAtandacknowledgedByUserId; subsequent authorized ACK calls return the sameacked: trueDTO without rewriting audit/provenance. - PATCH, DELETE, and ACK serialize their state decision (row lock or equivalent full-state CAS). In an ACK-vs-edit/delete race, exactly one transition wins: ACK first makes the note immutable; edit/delete first removes it from the ackable state.
- DELETE is a withdrawal, not a physical delete. It clears the live text from the note row, stamps withdrawal provenance, cancels
armAt, and records the final live snapshot in audit inside the same transaction.
Armed delivery (normative)¶
- Reuse
ATTENDANCE_ARM_GRACE_MINUTESandATTENDANCE_SWEEP_INTERVAL_SECONDS, including the test-default interval of0. Do not readATTENDANCE_REMINDER_DELAY_MINUTES. - Extract a small shared timing resolver for the grace + sweep values so attendance and disciplinary notes cannot drift while retaining the existing environment-variable names and behavior. The attendance-specific resolver composes the shared pair with its reminder delay.
DisciplinaryNotesNotifierServiceowns one dynamic interval and a publicsweep()for deterministic tests. It follows the attendance template: re-entrancy guard, enumerate tenants,withTenantGucper tenant, due scan, one full-predicate CAS claim per row, commit, then send.- Claim predicate includes
id,tenantId,state = PENDING,armAt <= now, and the scannedrevision. The claim moves the current revision toDELIVERED, clearsarmAt, stampsnotifiedAt, and initializesfirstNotifiedAtonce. - Recipient ids are resolved at claim time from current
StudentReferentLink → Referent.userId → active User, irrespective ofcanWrite. Dedupe user ids. No recipient rows or snapshots are stored. - Zero recipients leaves the DELIVERED claim standing. A referent linked later sees and may acknowledge the delivered note; an unlinked referent immediately loses access.
- Each claim commits before
NOTIFICATION_PORT.send(). Engine v1 remains at-most-once: death between claim and send loses that delivery, and a concurrent edit after the committed claim may cause the old lean notification to arrive before the newly armed revision. The live/pending detail gate remains authoritative. - Delivery claims emit no audit row.
notifiedAt/revisionare machine evidence; audit remains a who-did-what trail.
4. Data model plan¶
Schema deltas¶
- Add
enum DisciplinaryNoteState { PENDING DELIVERED ACKNOWLEDGED WITHDRAWN }. - Add
DisciplinaryNotewith required hard FKstenantId,academicYearId, andstudentId(Restrict), plus the inverse relations on Tenant/AcademicYear/Student. - Content:
date DateTime @db.Date, nullabletext String? @db.Text. API creation/active-state validation requires trimmed non-blank text and imposes no arbitrary character cap; withdrawal clears it. - Authorship snapshots:
recordedByUserId(soft UUID),recordedByName,createdAt; edit provenancelastModifiedByUserId?,lastModifiedByName?,updatedAt. - Delivery:
state,revision Int @default(1),armAt?,firstNotifiedAt?,notifiedAt?(current revision). - Shared acknowledgement:
acknowledgedAt?,acknowledgedByUserId?(internal only). - Withdrawal:
withdrawnAt?,withdrawnByUserId?,withdrawnByName?. Student/date/author/timestamps remain as the minimal tombstone and authorization anchor. - Add the standard Class-S RLS policy and update
src/prisma/rls-coverage.tsplussrc/prisma/tenanted-models.ts.
Migration shape¶
- Additive schema migration: one enum, one table, FKs, indexes, lifecycle CHECK, FORCE RLS policy.
- Data backfill: none.
- Hazards from chapter 12 checklist: inspect for an uncommitted migration before generation; do not fold without user direction; audit enum/table SQL, named indexes,
RestrictFKs, raw CHECK, ENABLE/FORCE RLS, and policyUSING+WITH CHECK; never edit a committed migration. - Lifecycle CHECK should require coherent field sets per state: active text for non-withdrawn rows;
armAtonly for PENDING; currentnotifiedAtfor DELIVERED/ACKNOWLEDGED; ack stamps only for ACKNOWLEDGED; withdrawal stamps + cleared text only for WITHDRAWN.
Indexes and uniqueness¶
- No business uniqueness: a student may have multiple notes on the same date, including by the same author.
@@index([tenantId, academicYearId, date])— default/year/date list.@@index([tenantId, studentId, academicYearId, date])— student-filtered history.@@index([tenantId, state, armAt])— due sweep.@@index([tenantId, academicYearId, state])—acked/family-state list filtering.
5. API surface¶
All routes live under @Controller('disciplinary-notes'), @ProtectedResource(), and @AppliesPolicy(DisciplinaryNotesPolicy). The entity is flat (FLAT_DTO_ENTITIES), so the DTO is not scope-grouped and no aggregate marker is needed.
| Verb | Path | Decorators | Request DTO | Response DTO |
|---|---|---|---|---|
| GET | /disciplinary-notes |
@RequireScope(DISCIPLINARY_NOTES, 'record', 'read') |
ListDisciplinaryNotesQueryDto |
paginated DisciplinaryNoteDto |
| GET | /disciplinary-notes/:id |
@RequireScope(..., 'record', 'read') |
UUID path | DisciplinaryNoteDto; authorized withdrawn target → 410 |
| POST | /disciplinary-notes |
@RequireAction(DISCIPLINARY_NOTES, 'create') |
CreateDisciplinaryNoteDto |
DisciplinaryNoteDto |
| PATCH | /disciplinary-notes/:id |
@RequireScope(..., 'record', 'write') |
UpdateDisciplinaryNoteDto |
DisciplinaryNoteDto in re-armed state (acked: false) |
| DELETE | /disciplinary-notes/:id |
@RequireAction(DISCIPLINARY_NOTES, 'delete'), 204 |
UUID path | empty; performs withdrawal |
| PATCH | /disciplinary-notes/:id/ack |
@RequireAction(DISCIPLINARY_NOTES, 'acknowledge') |
UUID path, no body | DisciplinaryNoteDto with acked: true |
CreateDisciplinaryNoteDto:
UpdateDisciplinaryNoteDto:
At least one PATCH field is required. studentId and every lifecycle/provenance/capability field are rejected by whitelist validation. Text is trimmed and must remain non-blank; no application-level maximum is imposed.
DisciplinaryNoteDto (same active shape for every role):
{
id: string;
student: { id: string; name: string };
date: string;
text: string;
recordedByName: string;
acked: boolean;
canEdit: boolean;
canDelete: boolean;
createdAt: Date;
updatedAt: Date;
}
No user ids, roles, delivery state/timestamps, acknowledgement actor/timestamp, withdrawal fields, or note text appear in a withdrawn response. For referents canEdit = canDelete = false; for staff the two flags share the mutation-authority predicate and are false once acknowledged.
List contract¶
ListDisciplinaryNotesQueryDto contains:
- standard
page,limit, optionalacademicYearId(default active); - optional case-insensitive student-name
q; - optional date-only inclusive
from/to(rejectfrom > to); - optional exact
ackedboolean; - optional
studentIdUUID; - optional paired
cohortType+cohortId, both or neither.cohortType ∈ { HOMEROOM, GRADE_GROUP, SUBJECT_GROUP, COMBINED_CLASS }using the attendance constant.
Omitted cohort means the entire policy-visible set. A supplied cohort is an additional current-school-day roster predicate and never widens visibility:
| Cohort type | Current-roster predicate |
|---|---|
HOMEROOM |
effective homeroom assignment to cohortId |
GRADE_GROUP |
effective placement with gradeId = cohortId |
SUBJECT_GROUP |
effective subject-group assignment to cohortId |
COMBINED_CLASS |
effective SG assignment whose SG is an effective member of cohortId |
Filters, year predicate, lifecycle visibility, and DisciplinaryNotesPolicy.where(ctx) compose under AND, never object spread. An invisible studentId filter yields an empty page. Ordering is fixed: occurrence date DESC, then createdAt DESC, then id ASC; pagination follows the standard { data, meta } contract. There is no notes-specific grouped/catalog endpoint.
Date rules¶
- POST/PATCH
dateis an occurrence day interpreted in the tenant's school timezone. - Reject
date > schoolToday. - The student must have an effective placement in the active academic year on that date. This prevents pre-enrolment and post-exit notes; closing-day/weekend membership is not separately prohibited.
- Every authorized author may backdate. Notification grace starts from create/edit instant, never the occurrence date.
Swagger considerations¶
- JSDoc describes only client-visible rules: current-roster visibility, optional cohort behavior, pending family visibility, re-arm-on-edit, shared explicit ack, acknowledgement lock, and withdrawal 410.
- Document
discipline.noteas notification-center rendering vocabulary with payload{ noteId, studentId, studentName, date, changeType: 'CREATED' | 'UPDATED' }; text is intentionally absent. - Add typed examples for hidden 404, 410 withdrawn, 409 acknowledged/immutable, 403 teacher-not-author, 422 future date, and 422 no effective placement.
- Publish Resend templates
discipline-note-enanddiscipline-note-itbefore deployment; variables contain no disciplinary text.
Error-code map¶
| Code | HTTP | Meaning |
|---|---|---|
DISCIPLINARY_NOTE_NOT_FOUND |
404 | Missing, foreign-tenant, outside current row scope, referent probing PENDING, or never-delivered withdrawal |
DISCIPLINARY_NOTE_WITHDRAWN |
410 | Authorized direct read/ack/mutation of a withdrawn row that is allowed to reveal its tombstone |
DISCIPLINARY_NOTE_ALREADY_ACKNOWLEDGED |
409 | PATCH or DELETE after shared acknowledgement |
DISCIPLINARY_NOTE_NOT_AUTHORIZED |
403 | A teacher tries to PATCH/DELETE a visible colleague-authored note |
DISCIPLINARY_NOTE_FUTURE_DATE |
422 | Occurrence date is after school-today |
DISCIPLINARY_NOTE_STUDENT_NOT_PLACED |
422 | Student has no effective active-year placement on the occurrence date |
Reuse VALIDATION_FAILED for empty/unknown PATCH fields, blank text, malformed date/UUID, invalid cohort pair/type, and inverted list date range. Create against an invisible student uses the existing hidden student NOT_FOUND behavior.
6. RBAC seed plan¶
| Seed file | Delta |
|---|---|
PermissionEntity (rbac-catalogue.ts) |
Add disciplinary_notes under the appropriate student-record/entity group with localized labels |
PermissionScope (rbac-catalogue.ts) |
Add zero-mapping disciplinary_notes.record scope |
PermissionAction (rbac-catalogue.ts) |
Add create, delete, acknowledge; add global typed action verb acknowledge |
ActionScopeRequirement |
create + delete require record:WRITE; acknowledge requires record:READ (the attendance justify precedent for a READ-level family action) |
ScopeFieldMapping |
none — flat DTO / zero-mapping scope |
Role grants (roles.ts) |
admin: automatic all; principal/secretary/department_head/curriculum_coordinator/teacher: record:WRITE + create/delete; referent: record:READ + acknowledge; student/hr/staff: none |
*_SCOPES runtime constant |
DISCIPLINARY_NOTE_SCOPES = { record: [] }; add entity to ENTITY_SCOPE_REGISTRY and FLAT_DTO_ENTITIES |
Record policy, not the grant matrix, supplies school-wide vs DEPARTMENT vs CURRICULUM vs taught-set vs linked-child rows. Department heads and curriculum coordinators are admin-like for operations on their visible rows. Teacher ownership is the one narrower in-service mutation fence.
Preset-role constants and expected-preset-grants.ts must be updated from the same exported sets so frozen-role reconciliation propagates the additions to tenant clones on reseed. Deployment requires the normal production reseed after the migration.
7. Divergence ledger¶
| Pattern | We diverge by | Reason | Tradeoff accepted |
|---|---|---|---|
| Attendance event lifecycle | Notes are editable/withdrawable until ack; every edit re-arms, and there is no reminder/follow-up | Product requires full CRUD plus a simple seen/not-seen marker | A delivered edit creates another notification and temporarily hides the note from family |
| Standard CRUD DELETE | DELETE writes a terminal tombstone and clears text instead of deleting the row | Old immutable notifications must resolve intelligibly; audit/authorization need a stable id/student anchor | Storage retains minimal metadata; callers receive 204 as if removed from their working set |
| Notification engine post-commit convention | Background CAS claim commits before send rather than registering a request-path post-commit job | Visibility is time-driven; the create/update request must not wait for grace | At-most-once crash window and an already-claimed send racing a later edit are accepted exactly as in attendance |
| Attendance-specific timing names | Disciplinary notes consume ATTENDANCE_ARM_GRACE_MINUTES and ATTENDANCE_SWEEP_INTERVAL_SECONDS |
Explicit product decision: identical arm behavior and one set of knobs | Configuration names are no longer literally domain-local; document them as shared family-alert cadence |
| Attendance/grades role surfaces | One shared list/detail endpoint serves staff and referents | The active row shape is identical; policy + lifecycle visibility fully determine rows | Service mapping must compute caller-specific capabilities without leaking pending rows |
BaseTenantedCrudService new-entity recipe |
Custom transactional service | Lifecycle, actor audit, current roster, ownership, withdrawal, and sweeper transitions are the feature | More explicit code, but no misleading generic hooks |
| First scheduled-job deferral said to revisit at job two | Add a second domain-owned interval rather than a general job framework | Two jobs do not yet justify a distributed registry; CAS supplies multi-process safety | Duplicate tenant enumeration/timer plumbing; revisit when a third cadence appears |
8. Pushback log¶
| Input says | Conflicts with | Agreed design | Status |
|---|---|---|---|
Initial mock includes Contesto and grade/class filters |
Product clarified that v1 is student-centric and class/course is navigation only | Remove context entirely; use one optional current-roster cohort pair | Resolved in chat 2026-08-11 |
| “Grouped like attendance” | Could imply a required cohort or a notes-specific board | Cohort is optional; omission is the whole visible list; filtered list only, no board/count endpoint | Resolved in chat 2026-08-11 |
| “Acked by referents” | Could imply per-recipient receipts | First current linked referent ack sets one shared marker; no per-referent tracking | Resolved in chat 2026-08-11 |
| “Full CRUD” | A family-visible acknowledged record cannot be silently rewritten or erased | CRUD only before ack; acknowledged is immutable/undeletable; DELETE before ack is audited withdrawal | Resolved in chat 2026-08-11 |
| “Just like attendance” notification | Attendance has reminder/follow-up/justification states | Reuse grace, sweep cadence, live recipients, CAS, and hidden pending visibility only; no reminder or follow-up | Resolved in chat 2026-08-11 |
| “Admin” | Repository has several management roles with row-parametric differences | Admin/secretary/principal school-wide; department head DEPARTMENT-scoped; curriculum coordinator CURRICULUM-scoped; same operations within visible rows | Resolved in chat 2026-08-11 |
| Generic notification marked read | Does not prove family acknowledgement | Explicit domain ACK endpoint; notification read remains independent | Resolved in chat 2026-08-11 |
| Notification could carry note text | Inbox/email are immutable while notes can be edited/withdrawn; text is sensitive | Payload/email omit text and open the live detail | Resolved in chat 2026-08-11 |
9. Deferrals¶
- Per-referent receipts, “all referents acknowledged,” recipient snapshots, preferences, and delivery reports — not needed for the shared v1 marker — revisit on product signal.
- Reminder/follow-up/escalation, family comments/replies, and office acknowledgement on behalf of family — explicitly outside the v1 note loop.
- Categories, severity, context, titles, attachments, bulk operations, imports/exports, and custom fields — keep the first content contract minimal.
- Student-role read/ack — only referents receive the family surface in v1.
- Dedicated per-note history endpoint — events remain discoverable in the central audit log; add only with a UI requirement.
- Notes-specific grouped board and per-cohort counts — existing class/course navigation + optional cohort filter is sufficient.
- Withdrawal notification — old delivery resolves to 410; no second notification kind in v1.
- Public delivery/ack provenance and delivery-state fields — public response remains
ackedplus capabilities. - General scheduled-job framework/distributed locks — two independent CAS-safe jobs remain acceptable; revisit at the next scheduled consumer.
10. Open questions¶
None — product decisions were resolved in chat 2026-08-11 and the design was explicitly approved before implementation.
11. Verification plan¶
The living implementation contract is chapter 25 — Disciplinary Notes; this approved design remains the decision history.
- Unit specs:
disciplinary-notes.policy.spec.ts: school-wide branches; dated DEPARTMENT/CURRICULUM branches; canonical teacher taught-set reuse; linked referent; student/hr/staff fail closed; old-year row filter still uses current-day relationship.disciplinary-notes.queries.spec.ts: visibility/year/filter fragments compose under AND; each cohort type uses current effective membership; omitted cohort adds no restriction; fixed ordering; referent state visibility; hidden vs revealable withdrawal.disciplinary-notes.service.spec.ts: create validation/provenance/audit; date non-future + placed-on-date; student immutability; teacher ownership; all admin-like roles mutate visible rows; PATCH re-arm matrix; ACK idempotence andcan*; acknowledged lock; withdrawal clears text/cancels arm/audits; race CAS/lock loser semantics; response mapping exposes only the agreed fields.disciplinary-notes-notifier.service.spec.ts: shared config; interval 0/no registration; due scan + full revision CAS; CREATED vs UPDATED; current active linked accounts/dedupe; ignorescanWrite; zero recipient claim; commit before send; per-tenant and per-row failure continuation; no reminder/audit.- Notification registry/email specs: typed
discipline.notepayload, notext, new email spec/variables, per-language alias, inactive/no-email recipient behavior. - Controller/decorator specs: flat-entity registration; scope/action/policy metadata;
acknowledgeaction typing; no student grant; frozen-preset expected grants. - E2E specs (
test/disciplinary-notes.e2e-spec.ts): - Role matrix: school-wide admin/secretary/principal; scoped department head/curriculum coordinator; teacher canonical taught set; linked referent delivered-only; student/hr/staff 403.
- Whole visible list with omitted cohort, then HOMEROOM/GRADE_GROUP/SUBJECT_GROUP/COMBINED_CLASS narrowing; current roster after a membership move; optional historical AY still current-authorized; filters/pagination/order.
- Create as each writer; teacher non-taught student hidden; valid backdate accepted; future/no-placement rejected; text trimmed/no arbitrary 2k cap.
- Teacher sees colleague note but cannot mutate it; author/admin-like can edit/withdraw while unacked; student id rejected on PATCH.
- Pending hidden from referent; deterministic sweep delivers one lean notification per current active linked referent; zero-recipient claim; generic notification read does not ack.
- Edit-before-send postpones one send; edit-after-send hides and re-arms with UPDATED notification; old payload contains no text.
- First referent ACK succeeds, second is idempotent,
canEdit/canDeletebecome false, PATCH/DELETE 409;canWrite=falselink may ack. - Pending withdrawal sends nothing and remains 404 family-side; delivered withdrawal disappears from lists and its old authorized link returns 410; no withdrawal notification.
- Multi-tenant isolation and RLS coverage; audit events for create/update/withdraw/ack, no sweeper audit row.
- Manual verification: with reduced shared grace in local env, create → staff pending → family hidden → sweep/delivery → explicit ack; repeat edit/re-arm and delivered-withdraw flows; verify inbox/email contains no note text.
- Documentation on implementation landing: add a disciplinary-notes domain chapter and
docs/REFERENCE.mdmodule/task-index entries; update chapter 23 notification registry/consumer table, RBAC chapter inventory/matrix, audit vocabulary, environment docs/template, and one FE guide covering list/detail/CRUD/ack/410 plus thediscipline.notepayload. - External deployment step: publish
discipline-note-enanddiscipline-note-itResend templates before enabling the consumer; reseed frozen role presets after deploy.
No build, lint, tests, migrations, or seed commands are run while authoring/approving this design.
12. Sign-off¶
- Approved by: Fabio Barbieri
- Date: 2026-08-11
- Chat reference: product brainstorming 2026-08-11; design walkthrough and explicit approval via “go on implementing the plan” in chat
The approved specification must be committed before implementation begins.