# SESSION_ATTENDANCE PRD

This is the product specification for the **Session Attendance** module of the infinitheism-backend product. Once a seeker is registered for a program session, the platform must resolve a single trustworthy answer to "did they attend?" from up to five independent sources, honouring a fixed authority hierarchy, and let the right people correct it. This document defines *what* the module does and *how we know it's done*; the *how* lives in the companion TRD, and the resolution behaviour is illustrated end-to-end in [use-cases.md](use-cases.md). It is read by the delivery team building it, the coordinators and RMs who operate it, and whoever signs off on "done". The module short code in IDs below is **SATT**. Strategic framing is in the [Vision](vision.md).

Today's baseline (what exists): [online-attendance.service.ts](../../../src/online-attendance/online-attendance.service.ts) appends events to a per-registration [`attendance_events`](../../../src/common/entities/program-attendance.entity.ts) log and flips a single `isAttended` boolean **on the first event** — order-of-arrival wins, there is no *absent* concept, no RM-vs-coordinator distinction, and `POST attendance/unmark` is a blunt global reset. This module replaces that resolution behaviour; the capture plumbing (join-click, Zoom, the `qr-attendance` module) is reused.

## Module cognition graph

The graph answers one question: **what does this module do for its users, and what is still undecided?** Read top-down: users and the goals they need, the requirements that serve those goals, the capture / marking / resolution / reporting flows in the middle, the decisions that govern them, the data they move, and the open questions still around the edges.

<details><summary>Graph: What does this module do, and what is still undecided?</summary>

```items
---
id: satt-prd-cognition
title: SESSION_ATTENDANCE PRD cognition
default_open_depth: 1
default_color_by: kind
color_palette_source: .daksh/color-palette.json
width: 95vw
---
Users:
  - user-01 :: Coordinator | kind: user | role: Session Coordinator | audience: client | status: placeholder | summary: Highest-authority operator; marks any registrant present/absent and undoes their own marks. | spec: [§User Stories](prd.md#user-stories)
  - user-02 :: Relationship Manager | kind: user | role: Relationship Manager (RM) | audience: client | status: placeholder | summary: Marks present/absent for only their assigned registrants; edits only their own marks. | spec: [§User Stories](prd.md#user-stories)
  - user-03 :: Registrant | kind: user | role: Seeker / Registrant | audience: end_customer | status: placeholder | summary: Attendee whose presence is captured passively via join-click, provider, or QR. | spec: [§User Stories](prd.md#user-stories)
  - user-04 :: Platform Operator | kind: user | role: Platform Operator | audience: delivery | status: placeholder | summary: Owns the source registry and precedence map; accountable for source extensibility and data migration. | spec: [§Business Rules](prd.md#business-rules)
Goals:
  - goal-01 :: One authoritative attendance status | kind: goal | success_measure: Every (registration, session) resolves to exactly one present/absent/unknown attendance status, deterministic from its active records. | summary: A single trustworthy attendance answer per registrant per session. | spec: [§Scope](prd.md#scope)
  - goal-02 :: Trust hierarchy honored | kind: goal | success_measure: In every source-conflict case, the highest-ranked active source decides. | summary: Human authority always outranks an automated signal. | spec: [§Business Rules](prd.md#business-rules)
  - goal-04 :: Reversible and auditable | kind: goal | success_measure: Any own-record mark can be undone, absence can be asserted, and every action is retained. | summary: Attendance is correctable and fully traceable. | spec: [§Business Rules](prd.md#business-rules)
Requirements:
  - req-01 :: Multi-source capture | kind: requirement | acceptance: Each source can append an event with a present/absent status against a registration's session row. | summary: All sources feed one append-only log. | spec: [§Acceptance Criteria](prd.md#acceptance-criteria)
  - req-02 :: Hierarchy resolution | kind: requirement | acceptance: Attendance status equals the status of the highest-ranked active record; UNKNOWN if none. | summary: Top-ranked active source wins, present or absent. | spec: [§Acceptance Criteria](prd.md#acceptance-criteria)
  - req-03 :: Owner-scoped edit/undo | kind: requirement | acceptance: An actor may add/edit/undo only the record they created; others are blocked; attendance status recomputes. | summary: You edit only your own mark; blocking is by ownership, not rank. | spec: [§Acceptance Criteria](prd.md#acceptance-criteria)
  - req-06 :: RM scoped to assigned registrants | kind: requirement | acceptance: An RM's mark on a registration not assigned to them is rejected. | summary: Each registration has one RM; only that RM may mark it. | spec: [§Acceptance Criteria](prd.md#acceptance-criteria)
Capture Flows:
  - flow-01 :: Join-click capture | kind: flow | actor: Registrant | trigger: Registrant opens their join link within the join window | outcome: A join-click present event appended | summary: Passive attendance from clicking to join (exists today). | spec: [§User Stories](prd.md#user-stories)
  - flow-02 :: Provider capture (Zoom) | kind: flow | actor: Online provider | trigger: Zoom webhook / reconciliation reports participant or absentee | outcome: A provider present or absent event appended | summary: Attendance pulled from the provider's own record (exists today). | spec: [§User Stories](prd.md#user-stories)
  - flow-05 :: QR-scan capture (deferred) | kind: flow | actor: Registrant | trigger: QR scan at the session (not built in MVP) | outcome: A QR present event appended | summary: Provider-class source modeled now, captured later. | spec: [§Scope](prd.md#scope)
Marking Flows:
  - flow-03 :: RM mark present/absent | kind: flow | actor: Relationship Manager | trigger: RM submits present or absent for an assigned registrant | outcome: An RM-level event appended; attendance status recomputes | summary: An RM asserts attendance for their own seekers. | spec: [§User Stories](prd.md#user-stories)
  - flow-04 :: Coordinator mark present/absent | kind: flow | actor: Coordinator | trigger: Coordinator submits present or absent for any registrant | outcome: A coordinator-level (top) event appended; attendance status recomputes | summary: The highest-trust manual signal; overrides by outranking, not editing. | spec: [§User Stories](prd.md#user-stories)
  - flow-07 :: Undo own mark | kind: flow | actor: Coordinator, RM, or system source | trigger: The record's owner reverses it | outcome: A reversal event appended; attendance status recomputes over remaining records | summary: Correcting a mark without erasing history. | spec: [§User Stories](prd.md#user-stories)
Resolution & System Flows:
  - flow-06 :: Resolve effective attendance status | kind: flow | actor: System | trigger: Any capture, mark, or undo event | outcome: The single present/absent/unknown attendance status recomputed by precedence | summary: The engine turning the event log into one authoritative answer. | spec: [§Business Rules](prd.md#business-rules)
  - flow-10 :: Migrate existing data | kind: flow | actor: System (one-off) | trigger: Rollout of the new resolution model | outcome: Historical rows recomputed under precedence; isAttended derived | summary: Backfilling legacy first-event data into the new model. | spec: [§Business Rules](prd.md#business-rules)
  - flow-14 :: Explain the attendance status | kind: flow | actor: Coordinator or RM | trigger: A user inspects why the attendance status is what it is | outcome: decidedBySource source + the ordered event log returned | summary: Every attendance status is traceable to the record that decided it. | spec: [§User Stories](prd.md#user-stories)
Reporting Flows:
  - flow-09 :: Session attendance report | kind: flow | actor: Coordinator or RM | trigger: GET session report | outcome: Per-registrant attendance status + source breakdown, paginated | summary: The resolved attendance for one session (extends today's report). | spec: [§User Stories](prd.md#user-stories)
  - flow-12 :: Program attendance report | kind: flow | actor: Coordinator | trigger: GET program report | outcome: Attendance rolled up across a program's sessions | summary: Program-level attendance aggregation (exists today). | spec: [§User Stories](prd.md#user-stories)
  - flow-13 :: Export report | kind: flow | actor: Coordinator | trigger: download=true on a report | outcome: An Excel export of the resolved attendance statuses + sources | summary: Excel export of attendance (exists today). | spec: [§User Stories](prd.md#user-stories)
Data Models:
  - dm-01 :: Attendance record | kind: datamodel | shape: program_user_attendance row (registration_id, session_id, attendance_status, decided_by_source, attendance_events[]) | summary: The per-registrant-per-session row carrying the resolved attendance status. | spec: [§Data Contract](prd.md#data-contract)
  - dm-02 :: Attendance event | kind: datamodel | shape: { source, status: present|absent, occurredAt, performedBy, reversalOf? } | summary: One immutable entry in the append-only log; now carries status + reversal. | spec: [§Data Contract](prd.md#data-contract)
  - dm-03 :: Source registry + precedence | kind: datamodel | shape: ordered map AttendanceSource -> rank (coordinator > RM > zoom > qr > join) | summary: The single declaration of which sources exist and how they rank. | spec: [§Business Rules](prd.md#business-rules)
Decisions:
  - dec-01 :: Top source wins, present or absent | kind: decision | alternatives: Present-always-wins; manual-beats-automatic with coordinator==RM. | reversal_trigger: A coordinator's call proves less reliable than the provider record in practice. | summary: The highest-ranked active source sets the attendance status, in either direction. | spec: [§Business Rules](prd.md#business-rules)
  - dec-03 :: Edits are owner-scoped | kind: decision | alternatives: Hierarchy-gated undo; any authorized user may undo any record. | reversal_trigger: Operators need a supervisor to edit a subordinate's record directly. | summary: An actor edits only the record they created; override is by adding a higher-ranked record, not editing theirs. | spec: [§Business Rules](prd.md#business-rules)
  - dec-05 :: Attendance status recomputed from the log | kind: decision | alternatives: Keep isAttended set on the first event. | reversal_trigger: Recompute cost at scale outweighs correctness. | summary: The attendance status is a pure function of the active event log; isAttended becomes derived. | spec: [§Business Rules](prd.md#business-rules)
  - dec-07 :: RM authorization is per-registration | kind: decision | alternatives: Any RM may mark any registrant. | reversal_trigger: RMs need to cover each other's registrants. | summary: An RM may mark only registrations assigned to them. | spec: [§Business Rules](prd.md#business-rules)
  - dec-08 :: Absent is an explicit event | kind: decision | alternatives: Treat "no present record" as absent. | reversal_trigger: Product wants implicit absence once the join window closes. | summary: Absence is a positively-recorded status distinct from "no record" (which is UNKNOWN). | spec: [§Business Rules](prd.md#business-rules)
  - dec-09 :: Undo falls to the next-ranked record | kind: decision | alternatives: Undo of the winner drops the attendance status straight to UNKNOWN. | reversal_trigger: Fallback produces surprising attendance statuses operators dislike. | summary: Undoing the deciding record recomputes over what remains, not to UNKNOWN. | spec: [§Business Rules](prd.md#business-rules)
Open Questions:
  - oq-01 :: Where does join-click rank? | kind: openquestion | summary: Among automated sources, is join-click above, below, or equal to provider and QR? | spec: [§Open Questions](prd.md#open-questions)
  - oq-04 :: Recurring/shared session granularity | kind: openquestion | summary: For same-link recurring sessions, is attendance per session row or per occurrence? | spec: [§Open Questions](prd.md#open-questions)
  - oq-05 :: Existing-data migration path | kind: openquestion | summary: How are historical rows recomputed or grandfathered, and is legacy MANUAL_ADMIN treated as RM or coordinator? | spec: [§Open Questions](prd.md#open-questions)
  - oq-06 :: RM identity source | kind: openquestion | summary: Enforcing RM scoping needs a real registration->RM id; today only an rmName string exists. | spec: [§Open Questions](prd.md#open-questions)
  - oq-07 :: Extend or replace mark/unmark endpoints | kind: openquestion | summary: Do we evolve POST attendance/mark + /unmark, or introduce status-carrying source-aware endpoints? | spec: [§Open Questions](prd.md#open-questions)

user-01 -> flow-04 | relation: experiences
user-01 -> flow-07 | relation: experiences
user-01 -> flow-09 | relation: experiences
user-02 -> flow-03 | relation: experiences
user-02 -> flow-07 | relation: experiences
user-02 -> flow-14 | relation: experiences
user-03 -> flow-01 | relation: experiences
user-03 -> flow-05 | relation: experiences
user-04 -> dm-03 | relation: owns
user-04 -> dec-05 | relation: owns
goal-01 -> goal-02 | relation: decomposes_into
goal-01 -> goal-04 | relation: decomposes_into
req-01 -> goal-01 | relation: serves
req-02 -> goal-02 | relation: serves
req-03 -> goal-04 | relation: serves
req-06 -> goal-01 | relation: serves
flow-01 -> dm-02 | relation: produces
flow-02 -> dm-02 | relation: produces
flow-05 -> dm-02 | relation: produces
flow-03 -> dm-02 | relation: produces
flow-04 -> dm-02 | relation: produces
flow-07 -> dm-02 | relation: produces
dm-02 -> flow-06 | relation: enables
dm-03 -> flow-06 | relation: enables
flow-07 -> flow-06 | relation: enables
flow-06 -> dm-01 | relation: produces
flow-06 -> goal-01 | relation: serves
flow-06 -> flow-09 | relation: enables
flow-06 -> flow-14 | relation: enables
flow-09 -> flow-12 | relation: enables
flow-09 -> flow-13 | relation: enables
flow-10 -> dm-01 | relation: produces
dec-01 -> flow-06 | relation: governs
dec-05 -> flow-06 | relation: governs
dec-08 -> flow-06 | relation: governs
dec-03 -> flow-03 | relation: governs
dec-03 -> flow-04 | relation: governs
dec-03 -> flow-07 | relation: governs
dec-07 -> flow-03 | relation: governs
dec-09 -> flow-07 | relation: governs
oq-01 -> flow-06 | relation: threatens
oq-04 -> goal-01 | relation: threatens
oq-05 -> flow-10 | relation: threatens
oq-06 -> flow-03 | relation: threatens
oq-07 -> flow-04 | relation: threatens
```

</details>

## Scope

**In scope (MVP)**

- Capture from join-click, Zoom provider, RM manual, and coordinator manual, each carrying an explicit present/absent status.
- Precedence-based resolution of one [effective attendance status](../../glossary#effective-attendance-status) per registration per session, replacing the first-event `isAttended` behaviour.
- Owner-scoped edit/undo, RM authorization scoped to assigned registrants.
- Session and program attendance reports and Excel export, surfacing the resolved attendance status and its source breakdown.
- One-off migration of existing attendance rows to the new model.

**Out of scope (MVP)**

- QR-scan **capture** (QR is modeled and ranked, not captured).
- Attendance notifications.
- A runtime/DB-editable source table (sources stay a code-level enum + precedence map).
- Per-occurrence attendance for recurring/shared sessions (pending `oq-04`).

**Deferred / future**

- QR-scan capture activation; additional providers (Teams, Meet) as new provider-class sources.

## User stories

Each story traces to the module Goals in the graph (this brownfield module has no separate BRD; the vision Goals are the requirement source of truth).

- **US-SATT-001** — As an **RM**, I want to mark an assigned registrant present or absent for a session, so that my first-hand knowledge is recorded. *(→ goal-02, req-01, req-06)*
- **US-SATT-002** — As a **Coordinator**, I want to mark any registrant present or absent, so that my call is the authoritative one when sources disagree. *(→ goal-02, req-01)*
- **US-SATT-003** — As **any marking actor**, I want to undo my own mark and have the attendance status recompute, so that mistakes are correctable without destroying history. *(→ goal-04, req-03)*
- **US-SATT-004** — As a **Registrant**, I want my join-link click to record my attendance automatically, so that I need no manual check-in. *(→ goal-01, req-01)*
- **US-SATT-005** — As the **System**, I want provider (Zoom) presence and absence captured automatically, so that attendance reflects the provider's own record. *(→ goal-01, req-01)*
- **US-SATT-006** — As a **Coordinator or RM**, I want to view the resolved attendance status and its source breakdown per session and program (and export it), so that I can act on accurate attendance. *(→ goal-01)*
- **US-SATT-007** — As a **Platform Operator**, I want to add or reorder an attendance source through the enum + precedence map without touching resolution logic, so that new sources are safe to add. *(→ goal-01; validates dec-02 from vision)*
- **US-SATT-008** *(future)* — As a **Registrant**, I want a QR scan at the venue to record my attendance. *(deferred; modeled via dec-04)*

## Business rules

- **BR-SATT-001 — Precedence ladder.** Sources rank: **Coordinator (5) > RM (4) > Zoom (3) > QR (2) > Join-click (1)**. The ordering within the automated tier is provisional (`oq-01`); coordinator > RM > automated is fixed. *Example:* a coordinator's absent and a Zoom present coexist → coordinator decides.
- **BR-SATT-002 — Resolution rule.** The [effective attendance status](../../glossary#effective-attendance-status) equals the status of the highest-ranked **active** (non-undone) record; if no active record exists, the attendance status is **UNKNOWN**. A higher source's *absent* overrides a lower source's *present* and vice-versa — only rank matters, never direction or arrival order.
- **BR-SATT-003 — Owner-scoped edits.** An actor may add, edit, or undo only the record **they** created. An attempt to alter another actor's record is rejected. Overriding a different source is done by adding one's own higher-ranked record (`dec-03`).
- **BR-SATT-004 — RM registrant scope.** An RM may mark only registrations assigned to them (`dec-07`); a mark on an unassigned registration is rejected. Coordinators are not registrant-scoped.
- **BR-SATT-005 — Explicit absence.** *Absent* is a positively-recorded status on an event, distinct from "no record" (`dec-08`). Join-click and QR are presence-only signals; Zoom, RM, and coordinator may record either status.
- **BR-SATT-006 — Undo semantics.** Undo appends a reversal event (nothing is deleted) and recomputes. If the undone record was the decider, the attendance status falls to the next-ranked active record, not straight to UNKNOWN (`dec-09`). *Example:* coordinator-present undone → falls to RM-absent → attendance status ABSENT.
- **BR-SATT-007 — Append-only audit.** The `attendance_events` log is never mutated or truncated; every mark, flip, and undo is retained with `source`, `status`, `performedBy`, and `occurredAt`.
- **BR-SATT-008 — Manual sources split.** The legacy single `MANUAL_ADMIN` source is replaced by two ranked manual sources (RM-level, coordinator-level) so the hierarchy can distinguish them (`dec-06` from vision).
- **BR-SATT-009 — Derived `isAttended`.** `isAttended` is no longer set on first event; it becomes a derived read of the attendance status (`attendanceStatus == present`), preserving report/consumer compatibility (`dec-05`).
- **BR-SATT-010 — Attendance status traceability.** Each record exposes which source decided the attendance status (`decidedBySource`) alongside the full ordered event log.
- **BR-SATT-011 — Single source registry.** The set of sources, their capture entry points, and their precedence live in one place; adding a source is an enum + precedence-map change reviewed like code, never a resolution-logic edit.

## Acceptance criteria

Given/When/Then; these are the sign-off criteria. Resolution cases enumerate against the truth table in [use-cases.md](use-cases.md).

- **AC-SATT-001** *(US-001, US-002)* — *Given* a registrant with only a Zoom-present record, *When* the RM marks them absent, *Then* the attendance status is **ABSENT** (RM outranks Zoom) and both records remain in the log.
- **AC-SATT-002** *(US-002)* — *Given* an RM-absent record, *When* the coordinator marks present, *Then* the attendance status is **PRESENT** without the RM's record being modified.
- **AC-SATT-003** *(US-003)* — *Given* a coordinator-present record deciding the attendance status, *When* the coordinator undoes it, *Then* the attendance status recomputes to the next-ranked active record (e.g. RM-absent → **ABSENT**).
- **AC-SATT-004** *(US-003, BR-003)* — *Given* a coordinator's record, *When* an RM attempts to undo it, *Then* the request is **rejected** (not the RM's record) and the attendance status is unchanged.
- **AC-SATT-005** *(US-001, BR-004)* — *Given* a registration assigned to RM A, *When* RM B attempts to mark it, *Then* the request is **rejected**.
- **AC-SATT-006** *(BR-002)* — *Given* a registrant with no records, *When* the attendance status is requested, *Then* it is **UNKNOWN**.
- **AC-SATT-007** *(US-004)* — *Given* an eligible registrant, *When* they click their join link in the join window, *Then* a join-click **present** event is appended and the attendance status is at least **PRESENT** absent any higher absent record.
- **AC-SATT-008** *(US-005, BR-005)* — *Given* Zoom reports a registrant in its absentee list, *When* provider capture runs, *Then* a Zoom **absent** event is appended and decides the attendance status unless an RM/coordinator record outranks it.
- **AC-SATT-009** *(US-006, BR-010)* — *Given* resolved attendance statuses for a session, *When* a coordinator opens the session report, *Then* each row shows the `attendanceStatus`, `decidedBySource` source, and the source breakdown, and export produces the same.
- **AC-SATT-010** *(US-007, BR-011)* — *Given* a new source added to the enum + precedence map, *When* the resolution engine runs, *Then* the new source is ranked correctly with **no change** to resolution code.
- **AC-SATT-011** *(BR-009)* — *Given* migrated legacy rows, *When* `isAttended` is read, *Then* it equals `attendanceStatus == present` for every row.

## Data contract

Shapes the module consumes and produces (not implementation).

**Consumes**

- **Mark request** (RM/coordinator) — `{ registrationId: number, status: "present" | "absent" }`. The **source** (RM-level vs coordinator-level) and **performedBy** are derived from the authenticated caller's role and id, never sent by the client. Extends today's [`MarkAttendanceDto`](../../../src/online-attendance/dto/mark-attendance.dto.ts) (which carries only `registrationId`).
- **Undo request** — `{ registrationId: number }`, targeting the caller's own record for their source on that session.
- **Provider capture** — the existing Zoom webhook / reconciliation payload, mapped to a `{ source: ZOOM, status }` event by the webhook adapter.
- **Join-click** — the existing join flow, mapped to a `{ source: JOIN_CLICK, status: present }` event.

**Produces**

- **Attendance event** appended to `attendance_events`: `{ source, status: "present" | "absent", occurredAt: ISO8601, performedBy: number | null, performedByRole: string | null, reversalOf?: eventRef }`. This extends the current [`AttendanceEvent`](../../../src/common/interfaces/attendance-event.interface.ts) by adding `status`, `performedByRole` (the actor's active role — one user id may hold several roles, so the role used for this mark is recorded), and `reversalOf`.
- **Resolved attendance status** on the record / in reports: `{ registrationId, sessionId, attendanceStatus: "present" | "absent" | "unknown", decidedBySource: source, events: AttendanceEvent[] }`.
- **`isAttended`** — retained as a **derived** boolean (`attendanceStatus == present`) for backward compatibility with existing report consumers.

**Cross-cutting dependency**

- Enforcing BR-SATT-004 requires a reliable **registration → RM id** relation. Today the entity stores an `rmName` string only; resolving this is `oq-06` and a precondition for RM scoping.

## Open questions

Stage 40b/40c must resolve these before build:

1. **Join-click ranking (`oq-01`).** Where does join-click sit among the automated sources relative to Zoom and QR?
2. **Recurring/shared session granularity (`oq-04`).** Per session row, or per occurrence?
3. **Migration path (`oq-05`).** How are historical rows recomputed, and is legacy `MANUAL_ADMIN` mapped to RM-level or coordinator-level?
4. **RM identity source (`oq-06`).** Is there a real registration→RM id to enforce scoping against, or must one be introduced?
5. **Endpoint evolution (`oq-07`).** Extend `POST attendance/mark` + `/unmark` to carry status and be source-aware, or introduce new endpoints and deprecate the blunt unmark?

---

Approved by:
Role:
Date:

Approved by:
Role:
Date:
