# SESSION_ATTENDANCE TRD

This is the technical design for the **Session Attendance** module, implementing [the PRD](prd.md). It replaces today's order-of-arrival `isAttended` behaviour with a deterministic, precedence-based resolution engine over an append-only event log, adds owner-scoped edits and explicit absence, and keeps the existing capture plumbing (join-click, Zoom, QR). It is read by the engineers building the module and the PTL signing off the design. The *what* is in the PRD; the illustrated behaviour is in [use-cases.md](use-cases.md). Module short code: **SATT**. Conforms to the repo baseline: **NestJS + TypeORM + PostgreSQL, TypeScript strict**, controller→service→repository layering, custom exceptions, DTO validation.

## Scope

**This TRD designs:** the source registry + precedence map, a pure resolution engine, the extended `AttendanceEvent` shape (status + reversal), the denormalised `attendance_status`/`decided_by_source` columns and their migration, the source-aware mark / owner-scoped undo endpoints, and role→source mapping. **Non-goals:** QR capture (deferred), notifications, per-occurrence attendance, and a runtime-editable source table.

## Module cognition graph

The graph answers one question: **where are the seams and contracts of attendance resolution, and what crosses them?** Read top-down: the API interfaces callers hit, the components behind them, the in-process resolution contract, the event that crosses every seam, the attendance status state machine, and the decisions that govern the design.

<details><summary>Graph: Where are the seams and contracts, and what crosses them?</summary>

```items
---
id: satt-trd-cognition
title: SESSION_ATTENDANCE TRD cognition
default_open_depth: 1
default_color_by: kind
color_palette_source: .daksh/color-palette.json
width: 95vw
---
API Interfaces:
  - int-01 :: Mark attendance API | kind: interface | shape: [contracts/mark-attendance.schema.json](contracts/mark-attendance.schema.json) | version: 2.0.0 | compatibility: breaking | summary: POST mark now carries an explicit present/absent status; source derived from caller role. | spec: [§API contracts](trd.md#6-api-contracts)
  - int-02 :: Undo attendance API | kind: interface | shape: [contracts/undo-attendance.schema.json](contracts/undo-attendance.schema.json) | version: 1.0.0 | compatibility: additive | summary: New endpoint; undoes the caller's own record for the session and recomputes. | spec: [§API contracts](trd.md#6-api-contracts)
  - int-03 :: Report API | kind: interface | shape: [contracts/report.schema.json](contracts/report.schema.json) | version: 1.1.0 | compatibility: additive | summary: Report rows gain attendance status + decidedBySource alongside the existing isAttended. | spec: [§API contracts](trd.md#6-api-contracts)
  - int-04 :: resolveAttendanceStatus() contract | kind: interface | shape: (events: AttendanceEvent[]) => { status, decidedBySource } | version: 1.0.0 | compatibility: additive | summary: The in-process, pure resolution contract every write path calls. | spec: [§Architecture overview](trd.md#3-architecture-overview)
Components:
  - comp-01 :: Attendance source registry | kind: component | boundary: Owns the AttendanceSource enum + precedence map + role→source mapping; no I/O. | summary: The single declaration of which sources exist and how they rank. | spec: [§Technology choices](trd.md#8-technology-choices)
  - comp-02 :: Resolution engine | kind: component | boundary: Pure function over an event array → attendance status; no DB, no clock, no role logic. | summary: Turns the event log into one authoritative attendance status; the module's deep core. | spec: [§Architecture overview](trd.md#3-architecture-overview)
  - comp-03 :: OnlineAttendanceService | kind: component | boundary: Orchestrates capture/mark/undo, authorization, recompute, and reports; no direct ORM. | summary: The application service wiring HTTP to the engine and repository. | spec: [§Architecture overview](trd.md#3-architecture-overview)
  - comp-04 :: OnlineAttendanceRepository | kind: component | boundary: All DB access for program_user_attendance; no business rules. | summary: Persistence for the attendance row and its event log. | spec: [§Persistence constraints](trd.md#5a-persistence-constraints)
  - comp-05 :: Provider webhook adapter | kind: component | boundary: Maps Zoom webhook/reconciliation payloads to present/absent events; provider-specific. | summary: Existing provider-capture seam feeding the log. | spec: [§Data flow](trd.md#7-data-flow)
  - comp-06 :: QrAttendanceService (external) | kind: component | boundary: Existing sibling module owning QR/manual check-in; appends events to the same log. | summary: Out-of-module capture seam that must use the same event contract. | spec: [§Data flow](trd.md#7-data-flow)
  - comp-07 :: Attendance status drift checker | kind: component | boundary: Periodic job re-deriving attendance status from the log and correcting/ alerting on mismatch. | summary: The safety net for denormalised-attendance status drift. | spec: [§NFR design](trd.md#10-nfr-design)
Events:
  - evt-01 :: AttendanceEvent appended | kind: event | payload_shape: { source, status, occurredAt, performedBy, performedByRole, reversalOf? } | summary: The one record that crosses every capture/mark/undo seam. | spec: [§Data model](trd.md#5-data-model)
  - evt-02 :: Provider participation reported | kind: event | payload_shape: Zoom participant/absentee webhook payload | summary: The external signal the adapter translates into evt-01. | spec: [§Idempotency and failure contracts](trd.md#6a-idempotency--failure-contracts)
  - evt-03 :: Attendance status recomputed | kind: event | payload_shape: { registrationId, sessionId, attendanceStatus, decidedBySource } | summary: The internal result of every append/undo; drives the persisted columns and reports. | spec: [§State machines](trd.md#5b-state-machines)
Lifecycle & Invariants:
  - sm-01 :: Attendance status lifecycle | kind: statemachine | entity: EffectiveAttendanceStatus | states: unknown, present, absent | initial_state: unknown | terminal_states: (none — always recomputable) | transitions: [§State machines](trd.md#5b-state-machines) | invariants_per_state: attendance status always equals the highest-ranked active record | summary: The recomputable attendance status per registration per session. | spec: [§State machines](trd.md#5b-state-machines)
  - inv-01 :: Attendance status equals highest-active | kind: invariant | violation_signal: A stored attendance status differs from resolveAttendanceStatus(events) for the same row. | summary: The persisted attendance status always matches what the engine derives from the active log. | spec: [§State machines](trd.md#5b-state-machines)
  - inv-02 :: Log is append-only | kind: invariant | violation_signal: An event's fields change, or the array shrinks, between reads. | summary: Events are never mutated or deleted; undo appends a reversal. | spec: [§State machines](trd.md#5b-state-machines)
  - inv-03 :: isAttended is derived | kind: invariant | violation_signal: A row where isAttended != (attendanceStatus == present). | summary: isAttended is a read of the attendanceStatus, never independently written. | spec: [§Data model](trd.md#5-data-model)
Data Models:
  - dm-01 :: Attendance row | kind: datamodel | shape: program_user_attendance (+ attendance_status, decided_by_source) | summary: The per-registrant-per-session row carrying the attendance status. | spec: [§Data model](trd.md#5-data-model)
  - dm-02 :: AttendanceEvent (extended) | kind: datamodel | shape: { source, status, occurredAt, performedBy, performedByRole, reversalOf? } | summary: The JSONB log entry, now carrying status + reversal. | spec: [§Data model](trd.md#5-data-model)
  - dm-03 :: Source registry map | kind: datamodel | shape: Record<AttendanceSource, rank:number> + role→source map | summary: The const precedence + role mapping the engine reads. | spec: [§Technology choices](trd.md#8-technology-choices)
Decisions:
  - dec-12 :: admin + shoba map to coordinator level | kind: decision | alternatives: Give admin its own top rank above coordinator; treat admin as RM-level. | reversal_trigger: Admin actions must be distinguishable from coordinator in the audit. | summary: Both admin and shoba (coordinator) marks resolve at coordinator rank. | spec: [§Security design](trd.md#9-security-design)
  - dec-01 :: Top source wins, present or absent | kind: decision | alternatives: Present-always-wins; manual==automatic. | reversal_trigger: Coordinator proves less reliable than provider in practice. | summary: Highest-ranked active record decides, either direction. | spec: [§Security design](trd.md#9-security-design)
  - dec-05 :: Attendance status recomputed from the log | kind: decision | alternatives: Keep isAttended set on first event. | reversal_trigger: Recompute cost at scale outweighs correctness. | summary: Attendance status is a pure function of the active event log. | spec: [§Architecture overview](trd.md#3-architecture-overview)
  - dec-09 :: Undo falls to next-ranked | kind: decision | alternatives: Undo of winner → UNKNOWN. | reversal_trigger: Fallback surprises operators. | summary: Undoing the decider recomputes over remaining records. | spec: [§State machines](trd.md#5b-state-machines)
  - dec-10 :: Denormalise attendance_status + decided_by_source | kind: decision | alternatives: Compute attendance status on every read from the log. | reversal_trigger: Denormalised columns drift from the log under a bug. | summary: Store attendance_status + decided_by_source columns (recomputed on every write) so reports and filters need no per-row replay. | spec: [§Persistence constraints](trd.md#5a-persistence-constraints)
  - dec-11 :: Keep MANUAL_ADMIN for migration | kind: decision | alternatives: Drop MANUAL_ADMIN, rewrite all rows. | reversal_trigger: Legacy rows fully migrated and verified. | summary: MANUAL_ADMIN stays in the enum, ranked at coordinator level, so legacy events resolve without a lossy rewrite. | spec: [§Data model](trd.md#5-data-model)
Runtime Risks:
  - risk-r1 :: Attendance status/log drift | kind: risk | likelihood: low | impact: high | mitigation: Recompute attendance status inside the same transaction as every event append; a periodic checker re-derives and alerts on mismatch. | phase: runtime | summary: Denormalised attendance status diverges from the event log. | spec: [§NFR design](trd.md#10-nfr-design)
  - risk-r2 :: Duplicate provider events | kind: risk | likelihood: medium | impact: medium | mitigation: Dedupe by (source, externalId) before append; resolution is idempotent to duplicates anyway. | phase: runtime | summary: At-least-once Zoom delivery double-appends. | spec: [§Idempotency and failure contracts](trd.md#6a-idempotency--failure-contracts)
  - risk-r3 :: RM scope unenforceable | kind: risk | likelihood: high | impact: medium | mitigation: Gate strict enforcement on a real registration→RM id (oq-06); until then log-and-allow with an explicit flag. | phase: runtime | summary: rmName string cannot reliably authorize an RM to a registrant. | spec: [§Security design](trd.md#9-security-design)

int-01 -> comp-03 | relation: enables
int-02 -> comp-03 | relation: enables
int-03 -> comp-03 | relation: enables
comp-03 -> comp-02 | relation: enables
comp-01 -> comp-02 | relation: enables
comp-02 -> int-04 | relation: produces
comp-03 -> comp-04 | relation: enables
comp-05 -> evt-01 | relation: produces
comp-03 -> evt-01 | relation: produces
comp-04 -> evt-01 | relation: produces
evt-02 -> comp-05 | relation: enables
evt-01 -> comp-02 | relation: enables
comp-02 -> sm-01 | relation: produces
dec-01 -> comp-02 | relation: governs
dec-05 -> comp-02 | relation: governs
dec-09 -> comp-02 | relation: governs
dec-10 -> comp-04 | relation: governs
dec-11 -> comp-01 | relation: governs
risk-r1 -> sm-01 | relation: threatens
risk-r2 -> comp-05 | relation: threatens
risk-r3 -> comp-03 | relation: threatens
comp-06 -> evt-01 | relation: produces
comp-02 -> evt-03 | relation: produces
evt-03 -> comp-04 | relation: enables
comp-04 -> dm-01 | relation: produces
comp-03 -> dm-02 | relation: produces
dm-03 -> comp-02 | relation: enables
dm-01 -> int-03 | relation: enables
inv-01 -> comp-02 | relation: watches
inv-02 -> comp-04 | relation: watches
inv-03 -> comp-03 | relation: watches
comp-07 -> inv-01 | relation: watches
dec-12 -> comp-01 | relation: governs
```

</details>

## 3. Architecture overview

The design separates **capture** (many sources append events) from **resolution** (one pure function decides the attendance status). Every write path — join-click, provider webhook, RM/coordinator mark, undo — does the same two steps inside one DB transaction: **append an event, then recompute the attendance status from the full active event log** and persist it. There is no path that mutates the attendance status directly, which is what makes the outcome deterministic and the audit trail complete.

The **resolution engine** (`comp-02`) is a pure function `resolveAttendanceStatus(events) → { status, decidedBySource }` with no DB, clock, or role knowledge — trivially unit-testable against the PRD truth table. The **source registry** (`comp-01`) is the only place that knows the source set, their precedence, and the role→source mapping; adding a source is a change here alone (satisfies PRD `req-01`/`goal-03`). The **service** (`comp-03`) owns authorization (owner-scoped edits, RM scope) and transactions; the **repository** (`comp-04`) owns persistence. This is chosen over embedding resolution in the service because the engine is the module's deep core and must be verifiable in isolation; the alternative (resolve inline in each write method) would scatter the precedence logic across capture paths and make `req-01`'s "add a source without touching resolution" impossible.

## 4. Component diagram

```mermaid
flowchart TD
  subgraph API
    I1[POST mark present/absent]
    I2[POST undo own]
    I3[GET reports]
  end
  I1 --> S[OnlineAttendanceService]
  I2 --> S
  I3 --> S
  ZW[Zoom webhook/recon] --> WA[Provider webhook adapter]
  JC[Join-click] --> S
  WA --> S
  S --> REG[Source registry: enum + precedence + role map]
  S --> ENG[Resolution engine: resolveAttendanceStatus]
  REG --> ENG
  S --> REPO[OnlineAttendanceRepository]
  REPO --> DB[(program_user_attendance)]
  ENG --> S
```

## 5. Data model

One table, extended — `program_user_attendance` already exists ([entity](../../../src/common/entities/program-attendance.entity.ts)). This module adds two denormalised columns and enriches the JSONB event shape. No new tables.

```sql
-- Additive migration (expand phase). No column drops.
ALTER TABLE program_user_attendance
  ADD COLUMN attendance_status  varchar(16) NOT NULL DEFAULT 'unknown',  -- present | absent | unknown
  ADD COLUMN decided_by_source  varchar(32) NULL;                        -- AttendanceSource that decided the attendance status

-- attendance_events jsonb entries gain:
--   status:         'present' | 'absent'  (legacy entries without status read as 'present')
--   performedByRole: string | null        (actor's ACTIVE role at mark time — one user
--                                           id may hold several roles; records which was used)
--   reversalOf:      string | undefined   (occurredAt+source ref of the event this reversal cancels)
```

```mermaid
classDiagram
  class ProgramUserAttendance {
    +int id
    +bigint registration_id
    +int session_id
    +string attendanceStatus         "present|absent|unknown (dec-10)"
    +string decidedBySource      "deciding AttendanceSource"
    +bool isAttended        "DERIVED: attendanceStatus == present (dec-05, BR-009)"
    +jsonb attendance_events "AttendanceEvent[] append-only"
  }
  class AttendanceEvent {
    +AttendanceSource source
    +string status          "present|absent (dec-08)"
    +string occurredAt      "ISO8601"
    +int performedBy        "actor id, null for system"
    +string performedByRole "active role at mark time (dec-12)"
    +string reversalOf      "optional; cancels a prior event"
  }
  ProgramUserAttendance "1" o-- "many" AttendanceEvent : attendance_events
```

### 5a. Persistence constraints

- **Primary key:** existing surrogate auto-increment `id`. Unchanged.
- **Uniqueness:** one active attendance row per `(session_id, registration_id)`; email-keyed rows created by Zoom reconciliation are reconciled into the registration row by the existing `findOrCreateForRegistration`. No new unique index required; the existing dedupe path is reused.
- **Foreign keys:** `registration_id`, `session_id`, `program_id`, `user_id` unchanged (nullable ManyToOne as today); on-delete behaviour unchanged.
- **Indexes:** add a partial/plain index on `(session_id, attendance_status)` to serve the session report's attendance status filter (replaces the current `isAttended` filter — see [§7](trd.md#7-data-flow)); add `(program_id, attendance_status)` for the program report roll-up. Name: `idx_pua_session_attendance_status`, `idx_pua_program_attendance_status`.
- **Retention:** attendance rows follow the program's retention; the event log is retained for the life of the row (audit requirement `BR-SATT-007`). No separate purge.
- **Migration policy:** expand/contract. This TRD is the **expand** phase — additive columns with defaults, backfill, dual-read (`isAttended` still written and derived). A later contract phase (out of scope) may drop `isAttended` as a stored column once all consumers read `attendanceStatus`.
- **Dedupe keys:** provider events dedupe on `(source, externalId)` where `externalId` is the Zoom participant/registrant id; join-click dedupes on `(source, registration_id)` (one click event per registration is sufficient).

### 5b. State machines

The **effective attendance status** per `(registration, session)` is a recomputable value, not a stored workflow — but its transition rules are the module's contract, so they are specified as a state machine. States: `unknown`, `present`, `absent`. Initial: `unknown` (no active records). No terminal state — any event or undo recomputes.

| From | To | Trigger / guard | Side effects |
|---|---|---|---|
| unknown | present | append event whose highest-active status = present | persist attendance_status, decided_by_source; isAttended=true |
| unknown | absent | append event whose highest-active status = absent | persist attendance_status, decided_by_source; isAttended=false |
| present | absent | append/undo makes the highest-active status = absent | recompute; audit event already in log |
| absent | present | append/undo makes the highest-active status = present | recompute |
| present/absent | unknown | undo removes the last active record | attendance_status=unknown, decided_by_source=null, isAttended=false |

**Invariant (all states):** `attendance_status` equals the status of the highest-ranked active (non-reversed) record, or `unknown` if none; `decided_by_source` names that record's source; `isAttended == (attendance_status == 'present')`.

**Recovery:** the periodic drift checker (`risk-r1` mitigation) re-derives the attendance status from the log and corrects any row where the stored attendance status disagrees, emitting a warning log for investigation.

```mermaid
stateDiagram-v2
  [*] --> unknown
  unknown --> present: highest active = present
  unknown --> absent: highest active = absent
  present --> absent: recompute (higher absent / undo)
  absent --> present: recompute (higher present / undo)
  present --> unknown: last active record undone
  absent --> unknown: last active record undone
```

## 6. API contracts

All endpoints live under `online-attendance` and reuse `CombinedAuthGuard + RolesGuard`. Producer: SESSION_ATTENDANCE. Consumers: web/admin clients.

**int-01 — Mark attendance `v2.0.0` (breaking):** `POST /online-attendance/sessions/:sessionId/attendance/mark`
- Roles: `admin`, `shoba` (coordinator), `relational_manager` (RM).
- Body: `{ registrationId: number (required, positive), status: "present" | "absent" (required) }`.
- Source derivation (server-side, never client-sent): `shoba`/`admin` → `MANUAL_COORDINATOR`; `relational_manager` → `MANUAL_RM`. `performedBy` = `req.user.id`.
- Breaking vs v1: `status` is now required (v1 implied present-only).

**int-02 — Undo attendance `v1.0.0` (additive):** `POST /online-attendance/sessions/:sessionId/attendance/undo`
- Body: `{ registrationId: number }`. Undoes the **caller's own** record (matched by derived source + `performedBy`) by appending a `reversalOf` event, then recomputes. Replaces the blunt global `unmark` for manual actors (unmark retained temporarily, deprecated — `oq-07`).

**int-03 — Reports `v1.1.0` (additive):** existing session/program report + export gain `attendanceStatus` and `decidedBySource` per row; `isAttended` retained.

**Enum tables**

| Field | Values |
|---|---|
| `status` | `present`, `absent` |
| `attendanceStatus` | `present`, `absent`, `unknown` |
| `AttendanceSource` | `MANUAL_COORDINATOR`, `MANUAL_RM`, `ZOOM_WEBHOOK`, `QR_SCAN`, `JOIN_CLICK`, `MANUAL_ADMIN` (legacy) |

**Error shape:** the repo-standard envelope `{ success:false, data:null, error:{ code, message } }` via custom exceptions — `InifniBadRequestException` (invalid status / not registered), `InifniNotFoundException` (session/record missing), forbidden via `RolesGuard`. No stack traces exposed.

### 6a. Idempotency & failure contracts

- **Idempotency:** marking the same `(source, status)` for a registration is idempotent — a repeated identical mark does not change the attendance status and appends at most one active event per (source, actor); duplicate provider events dedupe on `(source, externalId)` (`risk-r2`).
- **Retry:** all writes are single-transaction and safe to retry (append + recompute are together atomic). Provider webhook processing is at-least-once; resolution is order-independent, so replays converge to the same attendance status.
- **Undo replay:** undoing an already-undone record is a no-op (the record is already inactive); the response returns the current attendance status.
- **Failure response replay:** a client retrying a failed mark gets the same deterministic attendance status, since resolution depends only on the persisted log.

## 7. Data flow

Primary flow — RM marks absent over an existing Zoom-present (PRD [AC-SATT-001](prd.md#acceptance-criteria)):

```mermaid
sequenceDiagram
  participant RM
  participant C as Controller
  participant S as Service
  participant E as ResolutionEngine
  participant R as Repository
  RM->>C: POST mark {registrationId, status: absent}
  C->>S: markAttendance(session, reg, status, roles, userId)
  S->>S: deriveSource(roles) = MANUAL_RM; assertRmScope(reg)
  S->>R: load attendance row (+ events)
  S->>S: append {MANUAL_RM, absent, performedBy}
  S->>E: resolveAttendanceStatus(events)
  E-->>S: { status: absent, decidedBySource: MANUAL_RM }
  S->>R: save row (events, attendance_status, decided_by_source, isAttended)
  S-->>C: AttendanceRecord (attendanceStatus + decidedBySource)
```

Undo happy path references the `present → absent` / `→ unknown` transitions in [§5b](trd.md#5b-state-machines).

## 8. Technology choices

Conforms to the repo baseline (NestJS, TypeORM, PostgreSQL, TypeScript strict) — not re-decided here. Module-discretionary choices: the **resolution engine is a plain pure module** (no framework, no DI) so it is unit-testable without a Nest test harness — justified by `req-02`'s determinism requirement; the **source registry is a const map** (`comp-01`) rather than a DB table (explicitly `dec-02` in the vision). No new libraries.

## 9. Security design

- **AuthN/AuthZ:** existing `CombinedAuthGuard + RolesGuard`; mark/undo restricted to `admin`, `shoba`, `relational_manager`.
- **Owner-scoped edits (`dec-03`, `BR-SATT-003`):** undo matches the event by derived source **and** `performedBy == req.user.id`; a mismatch throws forbidden. Coordinators override by adding their own higher-ranked record, never by editing another's.
- **RM registrant scope (`dec-07`, `BR-SATT-004`):** enforced by `assertRmScope`. **Known gap (`risk-r3`, `oq-06`):** registration→RM is only an `rmName` string today; until a real RM **id** exists, strict rejection cannot be guaranteed, so the check logs-and-allows behind a flag rather than silently claiming enforcement.
- **Audit:** every event carries `performedBy` + `occurredAt`; the log is append-only (`BR-SATT-007`). No internal details leak in error responses.

## 10. NFR design

- **Correctness/reliability:** attendance status recomputed in the same transaction as the append (`risk-r1`); periodic drift checker re-derives and alerts. Resolution is O(n) over a small per-registration event array (single digits typically).
- **Performance:** denormalised `attendance_status`/`decided_by_source` (`dec-10`) keep reports and filters index-served without per-row log replay; new indexes in [§5a](trd.md#5a-persistence-constraints).
- **Scalability:** no cross-row coordination; each registration resolves independently.

### 10a. Deployment & operations

No dedicated operations map exists for this brownfield project (stage 35 not run); this module ships within the existing backend deployment. **Config surface:** one flag, `ATTENDANCE_STRICT_RM_SCOPE` (default off until `oq-06` resolves), gating `risk-r3`. **Migration reversibility:** the expand migration is additive (columns have defaults); rollback drops the two columns and the module reverts to reading `isAttended`. **Observability:** structured logs on mark/undo/recompute via the existing `AppLoggerService`; the drift checker emits a warning metric on mismatch.

## 11. Testing strategy

- **a. Types in scope:** **Unit** (in — resolution engine + source registry + role mapping, the risk core), **Integration** (in — service mark/undo/recompute against a test DB and the report projection), **Contract** (in — the mark v2 / undo v1 request/response schemas), **Security** (in — owner-scope + role authz rejection paths). **Out:** e2e/system (no UI in this module), performance/load (traffic is low, per-row O(n)), accessibility (no UI), chaos (no queues owned here) — each omitted with reason.
- **b. Coverage targets:** resolution engine **100% branch** (it is pure and small; the truth table demands it); service mark/undo/recompute **≥85% line**; every authz rejection path has at least one test.
- **c. Risk-based focus:** the resolution engine and the append+recompute transaction (wrong attendance status = wrong attendance record) get the heaviest investment; the migration backfill is verified on a copy of legacy rows.
- **d. Test data:** factory builders for `AttendanceEvent[]` permutations (one per truth-table row) and for attendance rows; legacy-shaped events (no `status`) included to prove back-compat reads as present.
- **e. Mock boundaries:** resolution engine — no mocks (pure). Service tests — real in-memory/test Postgres via the repository; provider webhook — faked adapter (external, at-least-once); auth — faked `req.user` with role arrays. No mocking of the engine in service tests (verify real resolution end-to-end).
- **f. Regression posture:** unit + contract on every PR; integration on every PR; migration test on release only.

## 12. Open questions

1. **Join-click ranking (`oq-01`)** — provisional Zoom>QR>join in the registry; confirm before GA.
2. **Granularity (`oq-04`)** — per session row vs per occurrence for recurring/shared sessions.
3. **RM id (`oq-06`)** — introduce a real registration→RM id to make `assertRmScope` strict; until then the flag defaults off.
4. **Endpoint deprecation (`oq-07`)** — timeline to remove the legacy blunt `unmark` once clients move to `undo`.
5. **Migration mapping (`oq-05`)** — confirm legacy `MANUAL_ADMIN` maps to coordinator-level rank.

---

Approved by:
Role:
Date:

Approved by:
Role:
Date:
