ADR 0007 — Launch scheduler, notifications, attendance
Status: Accepted (2026-04-17, Sprint F.5) Supersedes: none Blocks: D.5 (notification transports), J.6 (attendance-driven gamification)
Context
After F.1 landed, every enrolled launch had a signed commitment but no time-awareness — the pre-launch page showed a placeholder countdown of "T+60 minutes from scoring". We need:
- A creator-chosen launch time with timezone awareness.
- Hatcher notifications pinged at a fixed envelope around launch (T-60, T-10, T+0).
- Creator-editable marketing copy on the pre-launch page.
- Attendance / conversion analytics on the pre-launch page, so the creator and the protocol can reason about whether their community showed up.
D.5 (Raj-gated push/telegram/email transports) is still blocked. This sprint must be useful and shippable without real transports.
Decision
1. Notification slots as rows in launch_notifications
- One row per
(scoreRequestId, offsetMinutes), UNIQUE on a dedupe key ({scoreRequestId}:{offset}) so re-running the scheduler with the same launch time is idempotent. - Default envelope is
[-60, -10, 0]. Custom offsets allowed in service code for future growth (T-24h drip, T+5 post-launch reminder) but the UI only surfaces the three defaults. - A slot gets
dispatchedAt+transportmarks when the dispatcher hands it to a transport. The row is never deleted — audit + analytics.
2. Transport is an interface; loggingTransport is the default
NotificationTransport = { name, deliver(slot) }. D.5 swaps in a Telegram, email, or push transport by changing one constructor arg.- Until D.5:
loggingTransportwrites a pino line + marks the slotdispatchedwith transport=logged. This proves the scheduler works end-to-end without committing us to any particular channel.
3. Dispatcher is a pure function + admin-token endpoint
runDispatcherTickis a pure function of(deps, now, limit). Drivable by (a) an admin-bearer-gated HTTP endpoint (POST /v1/launch/dispatch), (b) a future setInterval tick inindex.ts, or (c) tests.- The admin token gate keeps the endpoint from being a free DoS / replay vector while we don't have a scheduled worker.
4. Pre-launch page copy is signature-gated
launch_pagesis one-to-one with an enrollment.- Updates require an EIP-191 signature from the enrollment's creator
address. Canonical message includes a client-supplied
updatedAtso stale captures can't be replayed (last-write-wins is fine — we don't need version vectors for marketing copy).
5. Attendance is session-hashed, not user-identified
- The web app generates a random hex string once per
sessionStorageand POSTs it on mount. Dedup constraint is UNIQUE(scoreRequestId,sessionHash) so replays / SPA nav don't inflate the count. - No IP, no user agent, no cookies beyond that session hash. Minimum surface area for analytics we don't need.
- Counters: unique sessions (attendance) + total hits (engagement).
6. Timezone awareness is presentational, data is UTC
- UI shows the user's IANA tz from
Intl.DateTimeFormat().resolvedOptions().timeZone. - The wire format is always ISO-8601 UTC. Creators see local, Hatchers see local, storage is canonical.
Alternatives considered
- Real background worker + Redis queue. Heavier than needed before
D.5 picks a transport. Current design upgrades cleanly: swap
runDispatcherinapp.tsfor a BullMQ-driven worker call. - Attach notifications as rows on
enrollments. Conflates commitment with scheduling; complicates idempotent re-scheduling when a creator edits their launch time. - Full page CMS. YAGNI — four text fields cover F.5's acceptance bar without needing a schema migration per copy change.
Risks
- Dispatcher endpoint is called by a cron (or a human) that must know
the admin token. If the token leaks, an attacker can trigger
dispatches out of order — but not create new rows, and
dispatchedAtidempotency prevents double-fire. Low blast radius. - Attendance pageviews can't be revoked if abused by a malicious
script; mitigated by the per-session unique constraint +
rate-limit on
/v1/launch/:scoreId/attend.
Follow-ups
- D.5: wire Telegram + email transports.
- F.5.1: creator-editable launch offsets (T-24h drip opt-in).
- G.1: consume attendance + notification data in the Hatcher feed.