# JOIN_LINK_GENERATION PRD

This is the product specification for the **Scheduled Join-Link Generation** module of the infinitheism-backend product. The module lets a program admin pick a future time at which the system automatically generates online-session join links (today, Zoom) for every eligible registrant of a program session — with a fallback safety net so a missed trigger never silently drops links. This document is read by the delivery team building it, the admins who operate it, and anyone signing off on what "done" means. Behavior lives here; the *how* lives in the [TRD](trd.md). The module short code used in IDs below is **JLG**.

Related code: [program-session.service.ts](../../../src/program-session/program-session.service.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). Companion narrative doc: [PROGRAM_SESSION_AND_JOIN_LINK_GENERATION.md](../../PROGRAM_SESSION_AND_JOIN_LINK_GENERATION.md).

## Vision — why this exists

Program sessions can have hundreds of registrants. Before this module, an admin had to trigger link generation manually and hope it completed; a browser timeout, a forgotten click, or an infra hiccup meant registrants showed up with no way to join. The vision is **"set a time and forget it"**: the admin schedules generation once, and the system guarantees — through a real-time scheduled event *and* a periodic fallback sweep — that every registrant ends up with a join link, exactly once, with no manual babysitting. The strategic bet is that reliability (links always arrive) and zero-touch operation matter more than instantaneous generation, so the design is deliberately *best-effort real-time, guaranteed-eventually* rather than synchronous.

## Module cognition graph

The graph below answers one question: **what does this module do for its users, and what is still undecided?** It wires each user to the flows they experience, the decisions that scope those flows, and the open questions that still threaten or extend them. Read it top-down: goals and requirements at the top, the flows that serve them in the middle, the decisions that govern them and the questions that remain around the edges.

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

```items
---
id: jlg-prd-cognition
title: JOIN_LINK_GENERATION PRD cognition
default_open_depth: 1
default_color_by: kind
color_palette_source: .daksh/color-palette.json
width: 95vw
---
Users:
  - user-01 :: Program Admin | kind: user | role: Program Admin | audience: client | status: placeholder | summary: Operates program sessions; schedules, reschedules, clears, and monitors join-link generation. | spec: [§User Stories](prd.md#user-stories)
  - user-02 :: Registrant | kind: user | role: Seeker / Registrant | audience: end_customer | status: placeholder | summary: The person who ultimately receives and uses the generated join link. | spec: [§User Stories](prd.md#user-stories)
  - user-03 :: Backend Operator | kind: user | role: Platform Operator | audience: delivery | status: placeholder | summary: Owns the feature flags and AWS wiring; accountable for the fallback safety net. | spec: [§Business Rules](prd.md#business-rules)
Goals:
  - goal-01 :: Automatic on-time delivery | kind: goal | success_measure: 100% of eligible registrants have a join link at/after the scheduled time, across all sessions. | summary: Links are generated automatically at the scheduled time without human action. | spec: [§Vision](prd.md#vision--why-this-exists)
  - goal-02 :: Zero manual effort | kind: goal | success_measure: Admin performs at most one action (set the time) per session; no per-registrant work. | summary: Admins never generate links one registrant at a time. | spec: [§Vision](prd.md#vision--why-this-exists)
  - goal-03 :: Exactly-once generation | kind: goal | success_measure: No registrant is double-registered; at most one bulk job runs per session per trigger window. | summary: A session's links are generated once even when multiple triggers race. | spec: [§Business Rules](prd.md#business-rules)
Requirements:
  - req-01 :: Links by fire time | kind: requirement | acceptance: After a session's fire time, every eligible registrant has a Zoom join link. | summary: Generation must cover all eligible registrants of the session. | spec: [§Acceptance Criteria](prd.md#acceptance-criteria)
  - req-02 :: Resilient to a missed event | kind: requirement | acceptance: A session whose real-time event never fired still gets links via the fallback within one sweep interval. | summary: A single missed/delayed AWS event must not drop links. | spec: [§Business Rules](prd.md#business-rules)
  - req-03 :: Admin visibility | kind: requirement | acceptance: Admin can read a status reflecting NOT_SCHEDULED/SCHEDULED/IN_PROGRESS/COMPLETED/FAILED. | summary: Admins can see whether generation is scheduled, running, done, or failed. | spec: [§Business Rules](prd.md#business-rules)
Admin Flows:
  - flow-01 :: Set / reschedule generation time | kind: flow | actor: Program Admin | trigger: PATCH /program-session/:id/link-generation with a future ISO time | outcome: linkGenerationAt persisted, status SCHEDULED, AWS schedule (re)registered | summary: Admin schedules or moves the time bulk generation fires for a session. | spec: [§User Stories](prd.md#user-stories)
  - flow-02 :: Clear / cancel schedule | kind: flow | actor: Program Admin | trigger: PATCH link-generation with null | outcome: status NOT_SCHEDULED, time cleared, AWS schedule deleted | summary: Admin cancels a previously scheduled generation. | spec: [§User Stories](prd.md#user-stories)
  - flow-03 :: Check generation status | kind: flow | actor: Program Admin | trigger: GET /program-session/:id | outcome: linkGenerationStatus + linkGenerationAt returned | summary: Admin inspects the current lifecycle state of generation. | spec: [§User Stories](prd.md#user-stories)
Scheduling Flows:
  - flow-04 :: Persist time + register schedule | kind: flow | actor: System | trigger: A valid set-schedule request | outcome: Direct UPDATE of time+status; idempotent AWS schedule join-link-<id> upserted | summary: Persists the intent and registers the one-time AWS schedule. | spec: [§Business Rules](prd.md#business-rules)
  - flow-05 :: Scheduled event fires | kind: flow | actor: AWS EventBridge Scheduler | trigger: Wall-clock reaches linkGenerationAt | outcome: JOIN_LINK_GENERATION message delivered to SQS and consumed | summary: At the scheduled time the event enqueues a generation request. | spec: [§Data Contract](prd.md#data-contract)
  - flow-06 :: Provision online session (prerequisite) | kind: flow | actor: Program Admin | trigger: Session configured as online with meeting/webinar/stream details | outcome: A provisioned online session that links can be generated against | summary: The session's online details must exist before generation can succeed. | spec: [§Scope](prd.md#scope)
Generation Flows:
  - flow-07 :: Claim + generate links | kind: flow | actor: System | trigger: Event fire or fallback sweep reaches this session | outcome: Session atomically claimed to IN_PROGRESS, bulk registration dispatched | summary: The single generation entry point; claims the session then bulk-registers all eligible registrants. | spec: [§Business Rules](prd.md#business-rules)
  - flow-08 :: Mark complete | kind: flow | actor: System | trigger: bulkRegister dispatched successfully | outcome: status COMPLETED (thin marker) | summary: Records that generation was dispatched for the session. | spec: [§Business Rules](prd.md#business-rules)
  - flow-09 :: Registrant receives join link | kind: flow | actor: Registrant | trigger: Links generated for the session | outcome: Registrant has a personal join URL to enter the session | summary: The end-customer outcome of the whole module. | spec: [§User Stories](prd.md#user-stories)
Recovery Flows:
  - flow-10 :: Fallback sweep (every 4h) | kind: flow | actor: System (cron) | trigger: 4-hourly cron tick with fallback flag on | outcome: Overdue, not-COMPLETED sessions re-enter generation | summary: Periodic safety net that catches sessions the real-time event missed. | spec: [§Business Rules](prd.md#business-rules)
  - flow-11 :: Retry on failure | kind: flow | actor: System | trigger: A generation attempt threw and status set FAILED | outcome: SQS redelivery (up to max attempts) or next sweep re-attempts | summary: Failed generations are retried rather than dropped. | spec: [§Business Rules](prd.md#business-rules)
  - flow-12 :: Stale in-progress reclaim | kind: flow | actor: System | trigger: An IN_PROGRESS older than the stale window (crashed run) | outcome: Session becomes re-claimable and is re-attempted | summary: Crashed runs stuck at IN_PROGRESS are recovered, not orphaned. | spec: [§Business Rules](prd.md#business-rules)
  - flow-13 :: Duplicate trigger skip | kind: flow | actor: System | trigger: A second trigger reaches a session already claimed | outcome: The losing trigger no-ops without a second job | summary: The consequence of the atomic claim — extra triggers do nothing. | spec: [§Business Rules](prd.md#business-rules)
Scoping Decisions:
  - dec-01 :: Best-effort scheduling, DB is source of truth | kind: decision | alternatives: Fail the API if AWS scheduling fails; use a synchronous transaction across API and AWS. | reversal_trigger: If missed events become common enough that eventual delivery is unacceptable, move to a stronger guarantee. | summary: The API never fails on AWS errors; persisted time+status drives recovery. | spec: [§Business Rules](prd.md#business-rules)
  - dec-02 :: Both paths flag-gated, default off | kind: decision | alternatives: Always-on; a single combined flag. | reversal_trigger: Feature graduates to GA and flags are removed. | summary: ENABLE_JOIN_LINK_SCHEDULER and ENABLE_JOIN_LINK_FALLBACK_CRON independently gate the two paths, default false. | spec: [§Business Rules](prd.md#business-rules)
  - dec-03 :: Admin-only operation | kind: decision | alternatives: Allow program managers or automated callers. | reversal_trigger: A new role needs scheduling rights. | summary: Only the admin role may set, clear, or view generation. | spec: [§Business Rules](prd.md#business-rules)
  - dec-04 :: Thin lifecycle status marker | kind: decision | alternatives: Track per-registrant status on the session; a full state table per registrant. | reversal_trigger: Admins need per-registrant status without opening the bulk job. | summary: linkGenerationStatus tracks only the dispatch; per-registrant results live on the bulk job. | spec: [§Business Rules](prd.md#business-rules)
Concurrency Decisions:
  - dec-05 :: Single atomic claim gates all triggers | kind: decision | alternatives: Advisory locks; a distributed lock; rely only on the unique index. | reversal_trigger: Claim contention or throughput demands a different concurrency primitive. | summary: A conditional UPDATE lets exactly one of event/cron/duplicate proceed per session. | spec: [§Business Rules](prd.md#business-rules)
  - dec-06 :: Grace + stale windows (30 min) | kind: decision | alternatives: No grace (cron competes immediately); longer/shorter windows. | reversal_trigger: Real generation runs approach the stale window, or events fire far from schedule. | summary: The cron skips just-fired events (grace) and reclaims crashed runs (stale). | spec: [§Business Rules](prd.md#business-rules)
  - dec-07 :: No DLQ; fallback cron is the net | kind: decision | alternatives: Dead-letter queue + manual replay; alerting-only. | reversal_trigger: Sessions repeatedly fail and silently accumulate without operator awareness. | summary: SQS retry plus the 4-hourly cron replace a dead-letter queue. | spec: [§Business Rules](prd.md#business-rules)
Open Questions:
  - oq-01 :: Timezone semantics for linkGenerationAt | kind: openquestion | summary: Should the API accept admin-local time and convert, or keep enforcing UTC ISO input? | spec: [§Open Questions](prd.md#open-questions)
  - oq-02 :: Alert admins on FAILED | kind: openquestion | summary: Should a failed generation notify the admin, or is status-on-demand enough? | spec: [§Open Questions](prd.md#open-questions)
  - oq-03 :: Fallback batch limit at scale | kind: openquestion | summary: Is 50 sessions per sweep sufficient when many sessions are overdue at once? | spec: [§Open Questions](prd.md#open-questions)
  - oq-04 :: Provider generalization beyond Zoom | kind: openquestion | summary: Should generation be provider-agnostic (Teams, Meet) rather than Zoom-only? | spec: [§Open Questions](prd.md#open-questions)
  - oq-05 :: Per-registrant failure visibility | kind: openquestion | summary: How should admins see which registrants failed without opening the bulk job? | spec: [§Open Questions](prd.md#open-questions)

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

</details>

## Scope

This module owns the *scheduling and orchestration* of bulk join-link generation for a single program session, plus the safety net that guarantees eventual delivery. It does not own how individual Zoom registrations are created — that is the online-session/Zoom bulk-registration subsystem, consumed here.

**In scope**

- Setting, rescheduling, clearing, and reading a session's `linkGenerationAt` and `linkGenerationStatus`.
- Registering/deleting the AWS one-time schedule per session (best-effort).
- Consuming the scheduled event and running generation through a single atomic-claim entry point.
- The 4-hourly fallback cron that re-attempts overdue, not-completed sessions.
- Concurrency safety between the event, the cron, and duplicate deliveries.

**Out of scope (consumed or deferred)**

- Per-registrant Zoom registration mechanics and the bulk job's internals (consumed via `bulkRegister`).
- Delivering the link to the registrant (email/comms subsystem) — modelled only as the end outcome `flow-09`.
- Non-Zoom providers (see OQ-JLG-004).
- Admin-facing per-registrant failure dashboards (see OQ-JLG-005).

## User Stories

Each story traces to the module goals/requirements above. The project-level BRD is inherited (pre-Daksh), so stories trace to `goal-*`/`req-*` in this PRD rather than to BRD UC/FR IDs.

- **US-JLG-001** — As a Program Admin, I want to schedule a future time for bulk join-link generation, so that registrants receive links without any manual effort. *(goal-01, goal-02; flow-01)*
- **US-JLG-002** — As a Program Admin, I want to reschedule or clear a scheduled generation, so that I can adapt when a session's timing changes. *(flow-01, flow-02)*
- **US-JLG-003** — As a Program Admin, I want to see the generation status of a session, so that I know whether links were created or something failed. *(req-03; flow-03)*
- **US-JLG-004** — As a Registrant, I want my join link created automatically, so that I can join the online session. *(goal-01; flow-09)*
- **US-JLG-005** — As a Backend Operator, I want a fallback that recovers missed generations, so that a single AWS hiccup never silently drops links. *(req-02; flow-10, flow-11, flow-12)*
- **US-JLG-006** — As a Backend Operator, I want the event and the fallback to never double-generate, so that registrants aren't double-registered and jobs aren't wasted. *(goal-03; flow-07, flow-13)*

## Business Rules

- **BR-JLG-001** — `linkGenerationAt` must be a valid future ISO-8601 (UTC) timestamp; `null` clears/cancels the schedule. *Example:* `"2026-07-10T09:30:00Z"` schedules; `null` cancels.
- **BR-JLG-002** — Only the `admin` role may set, clear, or read generation state. Non-admin → 403; unauthenticated → 401.
- **BR-JLG-003** — Setting a time registers a deterministic, idempotent AWS schedule named `join-link-<programSessionId>`; clearing deletes it. Re-setting replaces (never duplicates) the schedule.
- **BR-JLG-004** — Scheduling is **best-effort**. If the scheduler is flag-disabled or misconfigured, or AWS errors, the persisted time+status still succeeds and the API returns 200 — recovery relies on the fallback.
- **BR-JLG-005** — At most one generation runs per session at a time. When the event, the cron, and/or a duplicate delivery race, **exactly one** wins the atomic claim and proceeds; the others no-op. *(flow-07, flow-13)*
- **BR-JLG-006** — A session that is overdue past the grace window (fire time older than 30 min) and not `COMPLETED` is re-attempted by the 4-hourly fallback cron. *Edge case:* a session whose event never fired is picked up on the next sweep.
- **BR-JLG-007** — A session stuck at `IN_PROGRESS` longer than the stale window (30 min) is treated as a crashed run and becomes re-claimable. A healthy run flips to `COMPLETED`/`FAILED` in seconds.
- **BR-JLG-008** — Generation is idempotent: already-registered `(registrant, session)` pairs are skipped, and the registration↔session unique index blocks duplicates even if two jobs somehow overlap.
- **BR-JLG-009** — Status lifecycle is `NOT_SCHEDULED → SCHEDULED → IN_PROGRESS → COMPLETED | FAILED`. Status is a *thin marker*; per-registrant success/failure lives on the bulk registration job (see [domain glossary](../../domain-glossary.md)).
- **BR-JLG-010** — Both trigger paths are flag-gated and default off: `ENABLE_JOIN_LINK_SCHEDULER` (real-time event) and `ENABLE_JOIN_LINK_FALLBACK_CRON` (sweep). Either can run without the other; running cron-only works without AWS.

## Acceptance Criteria

These are the client sign-off criteria. Given/When/Then.

- **AC-JLG-001** *(US-JLG-001)* — **Given** an authenticated admin and a valid future ISO time, **When** they PATCH `/program-session/:id/link-generation`, **Then** `linkGenerationAt` is persisted, `linkGenerationStatus = SCHEDULED`, the `join-link-<id>` schedule is registered (when the scheduler flag is on), and the API returns 200.
- **AC-JLG-002** *(US-JLG-002)* — **Given** a session with a scheduled generation, **When** the admin PATCHes `linkGenerationAt = null`, **Then** status becomes `NOT_SCHEDULED`, the time is cleared, and the AWS schedule is deleted (a no-op if none exists).
- **AC-JLG-003** *(US-JLG-003)* — **Given** any session, **When** the admin GETs it, **Then** the response reflects the current `linkGenerationStatus` and `linkGenerationAt`.
- **AC-JLG-004** *(US-JLG-004)* — **Given** the fire time arrives and generation runs to completion, **When** the bulk job is dispatched, **Then** every eligible registrant of the session has a join link and status is `COMPLETED`.
- **AC-JLG-005** *(US-JLG-005)* — **Given** the AWS event never arrived and `ENABLE_JOIN_LINK_FALLBACK_CRON` is on, **When** the next 4-hourly sweep runs, **Then** the overdue session is generated and reaches `COMPLETED`.
- **AC-JLG-006** *(US-JLG-006)* — **Given** the event and the cron target the same session at the same time, **When** both attempt generation, **Then** exactly one wins the claim and generates, the other no-ops, and no second bulk job is created.
- **AC-JLG-007** *(US-JLG-001..003, negative)* — **Given** invalid input, **Then**: a non-ISO non-null time → 400; a non-admin → 403; a missing/deleted session → 404.

## Data Contract

What this module consumes and produces (shapes, not implementation).

**Consumes**

- `SetLinkGenerationDto` — `{ linkGenerationAt: string | null }` (future ISO-8601 UTC, or null).
- Program session id (path param) and the acting admin user (from auth).
- `bulkRegister({ sessionId, role, batchSize }, actorUserId)` from the online-session subsystem.

**Produces**

- API response — `{ id, linkGenerationAt, linkGenerationStatus }`.
- SQS message `JOIN_LINK_GENERATION` — `{ programSessionId, role?, batchSize?, actorUserId? }`.
- A bulk registration job reference — `{ jobId, total }` (per-registrant results live on the job).

**Cross-module dependencies** (candidates for the roadmap's contract register): online-session bulk registration, aws-scheduler, and the queue/SQS poller. Cross-module contracts are not tracked formally at this weight class (`cross_module_contracts: false`).

## Open Questions

Mandatory section — items the solution/TRD stages (or product) must resolve.

- **OQ-JLG-001** — Timezone semantics: should `linkGenerationAt` accept admin-local time and convert server-side, or keep enforcing UTC ISO input? *(threatens clean UX of flow-01)*
- **OQ-JLG-002** — Failure alerting: should a `FAILED` generation actively notify the admin, or is on-demand status (flow-03) sufficient?
- **OQ-JLG-003** — Fallback batch limit: is `FALLBACK_BATCH_LIMIT = 50` sessions per sweep enough when many sessions go overdue simultaneously (e.g. after a long AWS outage)?
- **OQ-JLG-004** — Provider generalization: should generation be provider-agnostic (Teams, Google Meet) rather than Zoom-only?
- **OQ-JLG-005** — Per-registrant visibility: how should admins see which registrants failed without opening the bulk job, given the thin status marker (dec-04)?

## Approval

Approved by:
Role:
Date:
