# JOIN_LINK_GENERATION TRD

This is the technical design for the **Scheduled Join-Link Generation** module, implementing the [JOIN_LINK_GENERATION PRD](prd.md). It specifies the seams between the program-session layer, the AWS scheduling adapter, and the generation/fallback runtime; the concurrency contract that keeps the scheduled event and the fallback cron from ever double-running a session; and the persistence and failure contracts that make eventual delivery reliable. It is read by the engineers building and extending the module and by the architect/PTL signing off on the design. The PRD says *what*; this doc says *how, and why this how*. Module short code in IDs: **JLG**.

Implemented by: [program-session.service.ts](../../../src/program-session/program-session.service.ts), [program-session.repository.ts](../../../src/program-session/program-session.repository.ts), [join-link-scheduler.service.ts](../../../src/join-link-scheduler/join-link-scheduler.service.ts), [join-link-generation.service.ts](../../../src/join-link-generation/join-link-generation.service.ts), [join-link-generation-fallback.service.ts](../../../src/join-link-generation/join-link-generation-fallback.service.ts), [join-link-generation.processor.ts](../../../src/queue/processors/join-link-generation.processor.ts).

## Design cognition graph

The graph below answers one question: **where are the seams and the contracts, and what crosses them?** It shows the components, the interfaces they produce/consume, the single scheduled event, the status state machine, the decisions that govern the concurrency-critical repository, and the runtime invariants/risks. Formal schemas and the full state table live in the prose sections below — the graph nodes link into them via `spec:`.

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

```items
---
id: jlg-trd-cognition
title: JOIN_LINK_GENERATION TRD cognition
default_open_depth: 1
default_color_by: kind
color_palette_source: .daksh/color-palette.json
width: 95vw
---
API & Orchestration Components:
  - comp-00 :: ProgramSessionController | kind: component | boundary: HTTP layer for program-session; validates DTOs, stamps actor, delegates to the service. Owns no business logic. | summary: REST controller exposing the link-generation endpoints. | spec: [§API Contracts](trd.md#api-contracts)
  - comp-01 :: ProgramSessionService | kind: component | boundary: Orchestration + transactions for sessions; calls the scheduler; never touches the ORM directly. | summary: Owns setLinkGeneration/markLinkGenerationStatus and the transaction boundary. | spec: [§Architecture Overview](trd.md#architecture-overview)
  - comp-02 :: ProgramSessionRepository | kind: component | boundary: All DB access for sessions; owns the atomic claim and the fallback candidate query. The concurrency hotspot. | summary: Data-access layer holding claimForLinkGeneration and findDueForFallbackGeneration. | spec: [§Persistence Constraints](trd.md#persistence-constraints)
Scheduling Components:
  - comp-03 :: JoinLinkSchedulerService | kind: component | boundary: Domain wrapper over AWS scheduling; owns the flag/config guard, deterministic name, and message shape. Best-effort. | summary: Registers/deletes the per-session AWS schedule. | spec: [§Architecture Overview](trd.md#architecture-overview)
  - comp-04 :: AwsSchedulerService | kind: component | boundary: Thin adapter over the AWS EventBridge Scheduler SDK (upsert/delete one-time schedules). | summary: The swappable AWS-specific adapter. | spec: [§Technology Choices](trd.md#technology-choices)
  - comp-09 :: SqsPollerService | kind: component | boundary: Long-polls the event queue and routes messages to processors. Shared infra, not JLG-specific. | summary: Delivers the scheduled event message to the processor. | spec: [§Data Flow](trd.md#data-flow)
Generation Components:
  - comp-05 :: JoinLinkGenerationProcessor | kind: component | boundary: Validates the JOIN_LINK_GENERATION message and delegates to the generation service; classifies errors for retry. | summary: SQS consumer for the scheduled event. | spec: [§Idempotency and Failure Contracts](trd.md#idempotency-and-failure-contracts)
  - comp-06 :: JoinLinkGenerationService | kind: component | boundary: The single generation entry point; claims the session, dispatches bulk registration, drives status. | summary: Owns generateForSession — the one gate all triggers pass through. | spec: [§Architecture Overview](trd.md#architecture-overview)
  - comp-07 :: JoinLinkGenerationFallbackService | kind: component | boundary: 4-hourly cron that sweeps overdue, not-completed sessions and re-attempts them via the generation service. | summary: The fallback safety-net scheduler. | spec: [§Architecture Overview](trd.md#architecture-overview)
Owned Contracts:
  - iface-01 :: SetLinkGenerationDto | kind: interface | shape: [§API Contracts](trd.md#api-contracts) | version: 1.0.0 | compatibility: additive | summary: Request body { linkGenerationAt: string|null } for the PATCH endpoint. | spec: [§API Contracts](trd.md#api-contracts)
  - iface-02 :: LinkGenerationResponse | kind: interface | shape: [§API Contracts](trd.md#api-contracts) | version: 1.0.0 | compatibility: additive | summary: Response { id, linkGenerationAt, linkGenerationStatus }. | spec: [§API Contracts](trd.md#api-contracts)
  - iface-04 :: JoinLinkScheduleInput | kind: interface | shape: [§API Contracts](trd.md#api-contracts) | version: 1.0.0 | compatibility: additive | summary: In-process input to createLinkSchedule { programSessionId, fireAt, actorUserId }. | spec: [§Architecture Overview](trd.md#architecture-overview)
  - iface-07 :: generateForSession entry point | kind: interface | shape: [§Idempotency and Failure Contracts](trd.md#idempotency-and-failure-contracts) | version: 1.0.0 | compatibility: additive | summary: The single in-process generation contract shared by the processor and the cron. | spec: [§Idempotency and Failure Contracts](trd.md#idempotency-and-failure-contracts)
External Contracts:
  - iface-05 :: bulkRegister contract | kind: interface | shape: [§API Contracts](trd.md#api-contracts) | version: 1.0.0 | compatibility: additive | summary: Cross-module call into the online-session subsystem to register all eligible registrants. | spec: [§API Contracts](trd.md#api-contracts)
  - iface-06 :: AWS Scheduler API | kind: interface | shape: [§Technology Choices](trd.md#technology-choices) | version: 1.0.0 | compatibility: additive | summary: External EventBridge Scheduler upsert/delete one-time schedule API. | spec: [§Technology Choices](trd.md#technology-choices)
  - iface-09 :: JOIN_LINK_GENERATION SQS queue | kind: interface | shape: [§API Contracts](trd.md#api-contracts) | version: 1.0.0 | compatibility: additive | summary: The event queue the schedule delivers into and the poller reads from. | spec: [§API Contracts](trd.md#api-contracts)
Structural Decisions:
  - dec-01 :: Best-effort scheduling, DB is source of truth | kind: decision | alternatives: Fail the API on AWS error; synchronous cross-system transaction. | reversal_trigger: Missed events become frequent enough that eventual delivery is unacceptable. | summary: Depth claim — the API/DB seam is authoritative; AWS is an accelerator, not a dependency. | spec: [§Architecture Overview](trd.md#architecture-overview)
  - dec-08 :: Direct UPDATE, never load-and-save | kind: decision | alternatives: Load the entity and re-save through the cascade. | reversal_trigger: The onlineSession relation stops being cascade-persisted. | summary: Depth claim — status/link writes bypass the entity cascade to avoid nulling the online-session FK. | spec: [§Persistence Constraints](trd.md#persistence-constraints)
  - dec-09 :: Claim inside generateForSession | kind: decision | alternatives: Claim in each caller (processor, cron) separately. | reversal_trigger: A new trigger path needs different claim semantics. | summary: Depth claim — one gate location means every current and future trigger is safe by construction. | spec: [§Idempotency and Failure Contracts](trd.md#idempotency-and-failure-contracts)
  - dec-10 :: Conditional UPDATE via query builder | kind: decision | alternatives: SELECT-then-UPDATE; advisory lock; ORM save. | reversal_trigger: The DB can no longer express the OR-stale predicate atomically. | summary: Depth claim — a single WHERE-guarded UPDATE is the atomicity primitive. | spec: [§Persistence Constraints](trd.md#persistence-constraints)
Concurrency Decisions:
  - dec-05 :: Single atomic claim gates all triggers | kind: decision | alternatives: Advisory/distributed lock; rely only on the unique index. | reversal_trigger: Claim contention or throughput demands a different primitive. | summary: Depth claim — the claim, not the bulk layer, is what prevents duplicate jobs. | spec: [§State Machines](trd.md#state-machines)
  - dec-06 :: Grace + stale windows (30 min) | kind: decision | alternatives: No grace; longer/shorter windows. | reversal_trigger: Real runs approach the stale window or events fire far off schedule. | summary: Depth claim — time windows separate a just-fired event from a crashed run without extra state. | spec: [§Persistence Constraints](trd.md#persistence-constraints)
  - dec-07 :: No DLQ; SQS retry + cron are the net | kind: decision | alternatives: Dead-letter queue + manual replay; alerting-only. | reversal_trigger: Sessions repeatedly fail and silently accumulate. | summary: Depth claim — durable DB state plus a periodic sweep subsumes a DLQ. | spec: [§Idempotency and Failure Contracts](trd.md#idempotency-and-failure-contracts)
Runtime Invariants:
  - inv-01 :: At most one generation per session | kind: invariant | violation_signal: Two bulk registration jobs created for the same session in an overlapping window. | summary: Only one generation run proceeds per session at a time. | spec: [§State Machines](trd.md#state-machines)
  - inv-02 :: Online-session FK never nulled by status writes | kind: invariant | violation_signal: 23505 on uq_online_session_program_id_template after a status/link write. | summary: Writing link fields must not clear hdb_online_session. | spec: [§Persistence Constraints](trd.md#persistence-constraints)
  - inv-03 :: COMPLETED sessions excluded from sweep | kind: invariant | violation_signal: A COMPLETED session re-enters generation via the cron. | summary: The fallback never re-runs a completed session. | spec: [§Persistence Constraints](trd.md#persistence-constraints)
Runtime Risks:
  - risk-01 :: Crashed run stuck IN_PROGRESS | kind: risk | likelihood: low | impact: medium | mitigation: Stale-window reclaim (dec-06) re-attempts after 30 min. | phase: runtime | summary: A process crash mid-generation leaves a session claimed forever. | spec: [§Idempotency and Failure Contracts](trd.md#idempotency-and-failure-contracts)
  - risk-02 :: Scheduled event never delivered | kind: risk | likelihood: medium | impact: high | mitigation: Fallback cron regenerates from persisted linkGenerationAt. | phase: runtime | summary: AWS schedule unregistered, flag off, or event aged out past 24h. | spec: [§Idempotency and Failure Contracts](trd.md#idempotency-and-failure-contracts)
  - risk-03 :: Claim bypass causes duplicate jobs | kind: risk | likelihood: low | impact: high | mitigation: Claim lives inside the single entry point (dec-09); unique index as backstop. | phase: runtime | summary: A trigger that skips generateForSession could double-generate. | spec: [§Idempotency and Failure Contracts](trd.md#idempotency-and-failure-contracts)

event-01 :: JOIN_LINK_GENERATION event | kind: event | payload_shape: { programSessionId, role?, batchSize?, actorUserId? } | summary: The one-time scheduled occurrence that triggers real-time generation. | spec: [§API Contracts](trd.md#api-contracts)
sm-01 :: JoinLinkGenerationStatus lifecycle | kind: statemachine | entity: ProgramSession.linkGenerationStatus | states: NOT_SCHEDULED, SCHEDULED, IN_PROGRESS, COMPLETED, FAILED | initial_state: NOT_SCHEDULED | terminal_states: COMPLETED (per cycle); NOT_SCHEDULED on clear | transitions: [§State Machines](trd.md#state-machines) | invariants_per_state: [§Persistence Constraints](trd.md#persistence-constraints) | summary: The thin per-session lifecycle marker for scheduled generation. | spec: [§State Machines](trd.md#state-machines)

iface-01 -> comp-00 | relation: enables
comp-00 -> comp-01 | relation: enables
comp-02 -> comp-01 | relation: enables
comp-01 -> iface-02 | relation: produces
comp-01 -> iface-04 | relation: produces
iface-04 -> comp-03 | relation: enables
comp-04 -> comp-03 | relation: enables
iface-06 -> comp-04 | relation: enables
comp-03 -> event-01 | relation: produces
event-01 -> comp-05 | relation: enables
iface-09 -> comp-09 | relation: enables
comp-09 -> comp-05 | relation: enables
comp-06 -> iface-07 | relation: produces
iface-07 -> comp-05 | relation: enables
iface-07 -> comp-07 | relation: enables
iface-05 -> comp-06 | relation: enables
dec-01 -> comp-03 | relation: governs
dec-05 -> sm-01 | relation: governs
dec-05 -> comp-02 | relation: governs
dec-05 -> comp-06 | relation: governs
dec-06 -> comp-07 | relation: governs
dec-06 -> comp-02 | relation: governs
dec-07 -> comp-07 | relation: governs
dec-08 -> comp-02 | relation: governs
dec-09 -> comp-06 | relation: governs
dec-09 -> sm-01 | relation: governs
dec-10 -> comp-02 | relation: governs
inv-01 -> comp-06 | relation: watches
inv-02 -> comp-02 | relation: watches
inv-03 -> comp-07 | relation: watches
risk-01 -> comp-06 | relation: threatens
risk-02 -> comp-05 | relation: threatens
risk-03 -> inv-01 | relation: threatens
```

</details>

## Scope

This TRD designs the scheduling, orchestration, concurrency, and recovery of bulk join-link generation for one program session. It does **not** design the Zoom bulk-registration internals (consumed via `bulkRegister`), the email/comms delivery of links, or the shared SQS poller (referenced only as an integration point).

## Architecture overview

The design separates three concerns behind clean seams so each can fail or be disabled independently:

1. **Intent capture (program-session layer).** The controller validates the request and delegates to `ProgramSessionService.setLinkGeneration`, which persists `linkGenerationAt` + `linkGenerationStatus` via a **direct column UPDATE** (never a load-and-save — see dec-08) and then asks the scheduler to register or delete the AWS schedule. The persisted DB state is the **source of truth**; scheduling is best-effort (dec-01).

2. **Real-time trigger (AWS → SQS → processor).** `JoinLinkSchedulerService` wraps `AwsSchedulerService` to upsert a deterministic one-time schedule `join-link-<id>`. At the fire time, EventBridge delivers a `JOIN_LINK_GENERATION` message onto the SQS event queue; `SqsPollerService` routes it to `JoinLinkGenerationProcessor`, which calls the single generation entry point.

3. **Generation + recovery (single gate).** Both the processor and the 4-hourly `JoinLinkGenerationFallbackService` call `JoinLinkGenerationService.generateForSession`, which **atomically claims** the session before doing any work (dec-05, dec-09). Exactly one caller wins; the loser no-ops. On success it marks `COMPLETED`; on failure `FAILED` and rethrows so SQS retries, with the cron as the ultimate net (dec-07).

This "best-effort real-time, guaranteed-eventually" shape is chosen over a synchronous API→AWS transaction because the latter couples request success to a third party and still cannot guarantee the future fire actually happens.

## Component diagram

The diagram shows the seams. Intra-module calls are NestJS dependency injection; cross-module and external calls cross the labelled interfaces.

```mermaid
flowchart TD
    ADMIN["Program Admin"] --> CTRL["ProgramSessionController"]
    CTRL --> SVC["ProgramSessionService"]
    SVC --> REPO["ProgramSessionRepository (atomic claim, candidate query)"]
    SVC --> SCHED["JoinLinkSchedulerService (flag/config guard)"]
    SCHED --> AWS["AwsSchedulerService"]
    AWS --> EB["AWS EventBridge Scheduler (external)"]
    EB -. "at fireAt" .-> SQS["JOIN_LINK_GENERATION SQS queue"]
    SQS --> POLL["SqsPollerService"]
    POLL --> PROC["JoinLinkGenerationProcessor"]
    CRON["JoinLinkGenerationFallbackService (every 4h)"] --> GEN["JoinLinkGenerationService.generateForSession"]
    PROC --> GEN
    GEN --> REPO
    GEN --> BULK["online-session bulkRegister (cross-module)"]
```

## Data model

The module adds three columns to the existing `hdb_program_session` table; no new table. Formal DDL:

```sql
ALTER TABLE hdb_program_session
  ADD COLUMN link_generation_at     timestamptz NULL,
  ADD COLUMN link_generation_status varchar(20) NOT NULL DEFAULT 'NOT_SCHEDULED';
-- updated_at (existing @UpdateDateColumn) participates in the stale-reclaim predicate.
```

```mermaid
classDiagram
    class hdb_program_session {
        bigint id PK
        timestamptz link_generation_at
        varchar(20) link_generation_status
        timestamptz updated_at
        int updated_by
        timestamptz deleted_at
    }
    class hdb_online_session {
        bigint id PK
        bigint program_id
        bigint program_session_id
    }
    hdb_program_session "1" --> "0..1" hdb_online_session : online details (cascade)
```

## Persistence Constraints

- **Primary key** — surrogate `id` (existing auto-int PK on `hdb_program_session`). No new PK.
- **Uniqueness** — none added by this module. The relevant existing constraint is `uq_online_session_program_id_template` on `hdb_online_session`, which the direct-UPDATE rule (dec-08) protects (inv-02).
- **Foreign keys / on-delete** — none added. Per project rule, no `ON DELETE CASCADE`; sessions are soft-deleted (`deleted_at`).
- **Indexes** — the fallback candidate query filters on `deleted_at IS NULL AND link_generation_at <= :dueBefore AND link_generation_status IN (...) OR (IN_PROGRESS AND updated_at < :staleBefore)`, ordered by `link_generation_at`. Recommended partial index: `CREATE INDEX ix_psession_link_gen_due ON hdb_program_session (link_generation_at) WHERE deleted_at IS NULL AND link_generation_status <> 'COMPLETED';` — serves the 4-hourly sweep query (`findDueForFallbackGeneration`). This index is a recommendation; at current session volumes a sequential scan every 4h is acceptable (see NFR design).
- **Retention** — the fields live for the lifetime of the session row; soft-delete retains them. No separate archival.
- **Migration policy** — additive only (two nullable/defaulted columns). Expand-only; no rewrite, no dual-write window needed. Backfill: existing rows default to `NOT_SCHEDULED`.
- **Dedupe keys** — generation dedupe is at the `(registration_id, program_session_id)` pair in the online-session subsystem (its unique index), not here. This module's dedupe is the **atomic claim** on `(id, link_generation_status)`.

**Per-state invariant (inv-02):** any write to `link_generation_*` MUST use `sessionRepo.update(...)` / a query-builder UPDATE, never a loaded-entity `save`, so the `onlineSession` cascade cannot null `hdb_online_session`.

## State Machines

`ProgramSession.linkGenerationStatus` is the one non-trivial lifecycle in this module. Vocabulary is authoritative and mirrored in [domain-glossary.md](../../domain-glossary.md); do not recoin these terms downstream.

```mermaid
stateDiagram-v2
    [*] --> NOT_SCHEDULED
    NOT_SCHEDULED --> SCHEDULED: admin sets linkGenerationAt
    SCHEDULED --> NOT_SCHEDULED: admin clears with null
    SCHEDULED --> IN_PROGRESS: claim won by event or cron
    FAILED --> IN_PROGRESS: reclaim via SQS retry or cron
    IN_PROGRESS --> IN_PROGRESS: stale reclaim after STALE_MS
    IN_PROGRESS --> COMPLETED: bulkRegister dispatched
    IN_PROGRESS --> FAILED: error, rethrown
    COMPLETED --> [*]
```

**Transitions**

| From | To | Trigger / guard | Side effects |
|---|---|---|---|
| NOT_SCHEDULED | SCHEDULED | Admin sets a future time | Persist time; upsert AWS schedule |
| SCHEDULED | NOT_SCHEDULED | Admin clears (null) | Clear time; delete AWS schedule |
| SCHEDULED / FAILED | IN_PROGRESS | Atomic claim wins (event or cron) | `updated_at` bumped; bulk job dispatched |
| IN_PROGRESS | IN_PROGRESS | Claim re-won after stale window (crashed run) | Re-dispatch |
| IN_PROGRESS | COMPLETED | `bulkRegister` returns | Status marked COMPLETED |
| IN_PROGRESS | FAILED | Generation throws | Status marked FAILED; error rethrown → retry |

**Invariants per state.** In `IN_PROGRESS`, at most one active generation exists per session (inv-01, enforced by the claim guard `status <> IN_PROGRESS OR updated_at < staleBefore`). In `COMPLETED`, the session is excluded from the sweep candidate query (inv-03). **Recovery transitions:** `FAILED → IN_PROGRESS` (retry/cron) and stale `IN_PROGRESS → IN_PROGRESS` (crash recovery) are the only exits from error/stuck states; both are automatic, no manual intervention.

## API Contracts

**1. `PATCH /program-session/:id/link-generation`** (admin only). Producer: this module. Consumer: admin UI / API client. Version 1.0.0.

Request (`SetLinkGenerationDto`):

| Field | Type | Required | Notes |
|---|---|---|---|
| `linkGenerationAt` | string \| null | yes | Future ISO-8601 UTC, or `null` to clear |

Response 200 (`LinkGenerationResponse`): `{ id: number, linkGenerationAt: string|null, linkGenerationStatus: enum }`.

`linkGenerationStatus` enum: `NOT_SCHEDULED | SCHEDULED | IN_PROGRESS | COMPLETED | FAILED`.

Error shape: `{ success: false, data: null, error: { code, message } }`. `400` invalid time; `401` unauthenticated; `403` non-admin; `404` session missing. No stack traces exposed.

**2. `JOIN_LINK_GENERATION` SQS message** (event-01). Producer: EventBridge schedule. Consumer: `JoinLinkGenerationProcessor`. Version 1.0.0.

```json
{
  "queueType": "EVENT",
  "subType": "JOIN_LINK_GENERATION",
  "timestamp": "<fireAt ISO>",
  "correlationId": "join-link-<programSessionId>",
  "data": { "programSessionId": 0, "role": "attendee", "batchSize": 0, "actorUserId": 0 }
}
```

`programSessionId` required; others optional. Invalid type or missing `programSessionId` → non-retryable failure.

**3. `bulkRegister` (consumed, iface-05, v1.0.0)** — `bulkRegister({ sessionId, role, batchSize }, actorUserId) → { jobId, total }`. Cross-module; treated as at-least-once and idempotent (see below).

## Idempotency and Failure Contracts

This module handles distributed state (SQS delivery) and drives external Zoom side effects, so the contracts are explicit.

- **Idempotency key** — the session id + `link_generation_status`. The atomic claim (`UPDATE ... WHERE status IN (SCHEDULED,FAILED) OR stale`) is the idempotency gate: a duplicate trigger for an already-`IN_PROGRESS` session loses the claim and no-ops. `generateForSession` returns normally on a lost claim (does not throw), so a duplicate SQS delivery is not treated as a failure and is not retried.
- **Retry semantics** — the processor classifies errors: validation/type errors are **non-retryable** (dropped); generation errors are **retryable**. SQS retry budget: `MAX_RETRY_ATTEMPTS = 5`, `MAX_EVENT_AGE_SECONDS = 86400` (24h). Beyond that the event ages out and the fallback cron is the net.
- **Duplicate callback handling** — SQS is at-least-once; duplicates are absorbed by the claim (above) and, as a backstop, by the online-session `(registration, session)` unique index which skips already-registered pairs.
- **At-least-once vs exactly-once** — delivery is at-least-once; **effective generation is exactly-once per session per cycle** via the claim (inv-01). risk-03 (a trigger bypassing the entry point) is mitigated structurally by keeping the claim inside `generateForSession` (dec-09).
- **Failure response replay** — the admin PATCH is idempotent: re-submitting the same time re-persists and re-upserts the (deterministically named) schedule without duplication.
- **No DLQ** — durable DB state + the 4-hourly sweep replace a dead-letter queue (dec-07). A crashed `IN_PROGRESS` is reclaimed after `STALE_MS` (risk-01).

## Data flow

Primary flow for US-JLG-001/004 (schedule → fire → generate). The happy path is below; every non-happy path is a State Machine transition (see [§State Machines](trd.md#state-machines)).

```mermaid
sequenceDiagram
    participant AD as Admin
    participant SVC as ProgramSessionService
    participant REPO as ProgramSessionRepository
    participant SCH as JoinLinkSchedulerService
    participant EB as EventBridge + SQS
    participant GEN as JoinLinkGenerationService
    participant BULK as bulkRegister

    AD->>SVC: PATCH link-generation (future ISO)
    SVC->>REPO: direct UPDATE time + status=SCHEDULED
    SVC->>SCH: createLinkSchedule(join-link-id)
    SCH-->>EB: upsert one-time schedule (best-effort)
    Note over EB: at fireAt
    EB->>GEN: JOIN_LINK_GENERATION (via poller + processor)
    GEN->>REPO: atomic claim to IN_PROGRESS
    REPO-->>GEN: claimed (affected=1)
    GEN->>BULK: bulkRegister(sessionId, role, batchSize)
    BULK-->>GEN: jobId, total
    GEN->>REPO: mark COMPLETED
```

## Technology Choices

Conforms to the project Technology Baseline: TypeScript, NestJS, TypeORM, PostgreSQL (inherited pre-Daksh; no roadmap §Technology Baseline exists to re-pin). Module-discretionary choices:

- **AWS EventBridge Scheduler** for one-time schedules — chosen over a self-managed cron table because it offloads durable time-triggering and retry to managed infra. Alternative considered: a DB-polled scheduler (rejected as duplicating the fallback cron's job at higher frequency).
- **Deterministic schedule name** `join-link-<id>` — makes create/update/delete idempotent; alternative (random names) would orphan schedules.
- **Query-builder conditional UPDATE** for the claim (dec-10) — chosen over SELECT-then-UPDATE (racy) and advisory locks (extra lifecycle) because a single WHERE-guarded UPDATE is atomic in Postgres.
- **`@nestjs/schedule` `@Cron`** for the fallback — already used in the codebase; no new dependency.

## Security Design

- **AuthZ** — every endpoint is `@Roles('admin')` behind `CombinedAuthGuard` + `RolesGuard`. The acting user id is taken from the authenticated principal, not the body.
- **AuthN** — bearer + user/active-role headers (existing scheme).
- **Data protection** — no new PII; join links live in the online-session subsystem. AWS creds/ARNs come from env, never hardcoded.
- **Audit** — `updated_by` is stamped on every status/link write.
- **No internal details leaked** — error envelope hides stack traces.

### IAM / AWS permissions (least-privilege contract for devops)

The module uses **Amazon EventBridge Scheduler** (IAM namespace `scheduler:`), *not* classic EventBridge (`events:`). Grant only the actions the code invokes ([aws-scheduler.service.ts](../../../src/aws-scheduler/aws-scheduler.service.ts)): `CreateSchedule`, `UpdateSchedule`, `DeleteSchedule`. Do **not** grant `scheduler:GetSchedule`, `ListSchedules`, any `*ScheduleGroup` action, or `events:*` — none are used.

**1. Application role** (the running backend). Scope schedules to the group + the deterministic `join-link-*` name prefix (`buildScheduleName` → `join-link-<programSessionId>`), and allow passing only the execution role to Scheduler:

```json
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "JoinLinkScheduleWrite",
      "Effect": "Allow",
      "Action": ["scheduler:CreateSchedule", "scheduler:UpdateSchedule", "scheduler:DeleteSchedule"],
      "Resource": "arn:aws:scheduler:<REGION>:<ACCOUNT_ID>:schedule/<GROUP_NAME>/join-link-*"
    },
    {
      "Sid": "PassExecutionRoleToScheduler",
      "Effect": "Allow",
      "Action": "iam:PassRole",
      "Resource": "<AWS_SCHEDULER_EXECUTION_ROLE_ARN>",
      "Condition": { "StringEquals": { "iam:PassedToService": "scheduler.amazonaws.com" } }
    }
  ]
}
```

> `<GROUP_NAME>` must equal `AWS_SCHEDULER_GROUP_NAME` (or `default` when it is unset). `iam:PassRole` is required because `CreateSchedule` embeds the execution `RoleArn`.

**2. Execution role** (assumed by Scheduler at fire time to deliver the event). Only needs to send to the target queue:

```json
{ "Effect": "Allow", "Action": "sqs:SendMessage", "Resource": "<AWS_SCHEDULER_TARGET_QUEUE_ARN>" }
```

Its trust policy allows `scheduler.amazonaws.com` to assume it, ideally with `aws:SourceAccount = <ACCOUNT_ID>` to prevent the confused-deputy problem. The **schedule group** is provisioned by devops (Terraform/console); the app is granted no group-management actions.

## NFR Design

- **Reliability** — dual delivery (event + 4-hourly cron) with no DLQ; eventual delivery guaranteed by durable DB state. Target: 100% of scheduled sessions reach `COMPLETED` within one sweep interval of their fire time.
- **Concurrency/correctness** — exactly-once effective generation via the atomic claim (inv-01).
- **Performance** — the sweep processes ≤ `FALLBACK_BATCH_LIMIT = 50` sessions/tick sequentially to bound bulk-job fan-out; the candidate query is a single indexed scan every 4h. Real-time path latency is dominated by SQS + bulkRegister, out of this module's control.
- **Scalability** — see OQ-JLG-003 (batch limit under mass overdue).

## Deployment & Operations

No project-level Operations Map exists yet (brownfield); this section documents the module's operational surface directly and should be reconciled into an Operations Map if one is created.

- **Environments** — deploys with the existing backend service (single deployable); no module-specific environment.
- **Configuration surface** — env vars: `ENABLE_JOIN_LINK_SCHEDULER`, `ENABLE_JOIN_LINK_FALLBACK_CRON` (both default false), `AWS_SCHEDULER_TARGET_QUEUE_ARN`, `AWS_SCHEDULER_EXECUTION_ROLE_ARN`, `AWS_SCHEDULER_GROUP_NAME`. Timing constants (`FALLBACK_CRON`, `GRACE_MS`, `STALE_MS`, `FALLBACK_BATCH_LIMIT`, `MAX_RETRY_ATTEMPTS`, `MAX_EVENT_AGE_SECONDS`) are code constants, not env. Documented in [.env.example](../../../.env.example).
- **Runtime invariants** — inv-01, inv-02, inv-03 (above).
- **Failure modes** — risk-01 (stuck IN_PROGRESS → auto-reclaim), risk-02 (event lost → cron), risk-03 (claim bypass → structural mitigation). None page on-call today; all auto-recover.
- **Rollback** — set both flags to `false` to disable both trigger paths without a deploy; the two additive columns need no rollback. No data migration to reverse.
- **Observability** — the scheduler, processor, generation service, and cron all log via `AppLoggerService` (created/updated/deleted schedule, claim skipped, generation dispatched/failed, sweep found N). No dedicated dashboards yet (candidate follow-up).

## Testing Strategy

- **Test types in scope.**
  - *Unit* — **in scope.** The claim predicate, status transitions, `setLinkGeneration` branching (set vs clear), scheduler flag/config guard, processor validation/error classification, and the cron candidate selection.
  - *Integration* — **in scope.** Repository claim against a real Postgres (concurrent-claim race: two callers, one winner); direct-UPDATE does not null `hdb_online_session` (inv-02); fallback candidate query returns overdue/not-COMPLETED/stale only.
  - *End-to-end / system* — **in scope (thin).** Set time → simulated event → generation → COMPLETED, and missed-event → cron → COMPLETED.
  - *Performance / load* — **out of scope** for now; sweep volume is low. Revisit under OQ-JLG-003.
  - *Security* — **in scope.** AuthZ: non-admin 403, unauthenticated 401 on all endpoints.
  - *Accessibility* — **out of scope** (no UI in this module).
  - *Contract* — **in scope.** The SQS message shape and the `bulkRegister` call contract (pinned v1.0.0).
  - *Chaos / resilience* — **in scope (targeted).** Kill mid-`IN_PROGRESS` and assert stale reclaim; duplicate SQS delivery asserts single generation.
  - *Exploratory / UAT* — **in scope** for admin scheduling UX before GA.
- **Coverage targets.** Unit ≥ 80% on the four JLG services + repository claim/candidate methods. Integration covers every state transition in [§State Machines](trd.md#state-machines) and the concurrent-claim race. Security covers all endpoints.
- **Risk-based focus.** Heaviest investment on the atomic claim (duplicate jobs = wasted work + Zoom rate pressure) and the direct-UPDATE rule (FK null-out = data corruption). These are the expensive-failure surfaces.
- **Test data strategy.** Factories build a program session with online details and N eligible registrations; the fallback query is exercised with fixtures at varied `link_generation_at`/`updated_at`/status combinations. Each test isolates its own session rows.
- **Mock boundaries.** DB: **real** (integration) / in-memory or faked (unit). `bulkRegister`: **faked** (slow + external; contract-tested separately). AWS Scheduler: **faked** (external; asserted via the adapter contract). SQS poller: **faked** in unit, real in the thin e2e.
- **Regression posture.** Unit + integration run on every PR. The concurrent-claim race and stale-reclaim chaos tests run on every PR (they are fast and guard the core invariant). E2e runs nightly and on release.

## Open Questions

- **OQ-JLG-101** — Should a partial index (`ix_psession_link_gen_due`) be added now, or deferred until sweep latency is measured? (Persistence.)
- **OQ-JLG-102** — Should timing constants (grace/stale/cron/batch) become env-configurable per environment, or stay code constants? (Ties to PRD OQ-JLG-003.)
- **OQ-JLG-103** — Should a `FAILED`-status metric/alert be emitted for on-call visibility? (Ties to PRD OQ-JLG-002.)
- **OQ-JLG-104** — Provider-agnostic generation (Teams/Meet) would require abstracting `bulkRegister`; design deferred (PRD OQ-JLG-004).

## Approval

Approved by:
Role:
Date:
