alxjrvs / alxjrvs/orrey

The Orrey — specification and roadmap

Đang mở
#1 0 bình luận 0 reaction 0 người được giao Xem trên GitHub
epic spec
Ngôn ngữ chính
HTML
Star
0
Fork
0
Merge trung bình
2 giờ 48 phút
Pull request đã merge (30 ngày)
4

Mô tả

This is the specification for the Orrey, previously `docs/build-plan.md` (rev 5, 13 Sept 2026). The roadmap is ordered by risk retired; each phase is a milestone with an epic below, and each epic's sub-issues are ordered. `CLAUDE.md` in the repo is the short version of the rules that are easy to violate by accident.

## Roadmap

- **Phase 0 — Footings** #2. Prove the Google auth pattern, deploy and verify, then cut over.
- **Phase 1 — One session, three surfaces** #3. One hardcoded running campaign → Discord event + attendance post + Google event.
- **Phase 2 — Lifecycle and cadence** #4. Campaigns/games/roster, full lifecycle, anchor+interval recurrence, horizon materialiser, plus a minimal console with Discord login.
- **Phase 3 — Does it run** #5. Quorum auto-confirm, jeopardy notices, reminder ladder with DM fallback, attendance auto-assume + correction, flake memory, per-session threads, `/upcoming`.
- **Phase 4 — Date polls, both uses** #6. Target → move a session. No target → pre-signup minting game days with a win rule. This *is* availability polling.
- **Phase 5 — Game days** #7. Single (capacity from game, waitlist auto-promotion) and multi (day-level seating, tables recorded after).
- **Phase 6 — The console, properly** #8. Agenda/month views, campaign pages, ICS feeds.
- **Phase 7 — Logs, memory, and the echo** #9. Threads, recaps, history, stats; then the Google return path. Last because it's the only loopable edge.

## Three premises

1. **Orrey is a reconciler, not a replicator** (for Discord scheduled events and Google; messages are send-only and never reconciled).
2. **There are three questions, not one.** "Which day?", "who's in?", "still coming?" — consecutive stages, different audiences, any may be skipped.
3. **Discord is identity.**

Central question is **"does it run"** — quorum, not just calendar. When the answer is no, the response is a date poll, not a cancellation.

## All settled decisions

**Shape**
- Source of truth: Orrey's own database. Discord + Google Calendar are displays.
- Stack: TypeScript on Cloudflare Workers, **built fresh**. Hermuz archived read-only, read as a specification, not ported.
- **Repurpose the existing Hermuz Discord application** rather than registering a new one.
- **One repo, one Worker**, schema + repositories in their own package.
- **No database import.** Fresh tables; four campaigns entered by hand; adopt only the existing Discord guild/role/channel ids.

**Discord surface**
- **Discord is the only identity system** — `identify` scope alone; roles read with the bot token; never request `guilds`.
- **Messages are send-only.** Orrey never edits a post it already sent. The one exception: **a click may rewrite the message it came from**, as that interaction's own response. No cron, projector or console action ever touches a posted message.
- **A small command surface** — four read-or-initiate commands, all answering ephemerally: `/upcoming`, `/reschedule`, `/whos-in`, `/console`. Administration stays in the console.
- Buttons remain the primary surface; every action sits on the thing it concerns.
- **Native scheduled events: yes**, horizon capped at two upcoming per campaign, treated as disposable.
- **Attendance: auto-assume** from intent at session end; organiser corrects via per-person toggles.

**Model**
- **Three stages** — date-finding, seating, attendance — any of which an event may skip.
- **Two kinds of event:** campaign session, one-off. Explicit `kind` column.
- **Two kinds of game day:** `single` (one long game — TI, Arcs; capacity from the game) and `multi` (a hangout, **seated at the day level**, tables recorded afterwards).
- **Signups attach to campaigns at formation and to game days — never to a campaign session or individual session.** CHECK constraint.
- Started campaigns skip stages 1 and 2 structurally.

**Date polls**
- **One mechanism, two uses:** no target = pre-signup minting game days from winning dates; with a target = reschedule that moves that session.
- **Players propose, GM confirms and triggers.** Via the attendance post's *Suggest another day* button or `/reschedule`. Max one open poll per session.
- **"Winning" is contextual and the organiser's call.** Default threshold from the game's min player count; alternatives quorum-of-roster, best-available, organiser-picks; override always available.
- **One poll may produce several game days.**
- **Auto-resolve is per-campaign opt-in**, never onto a date the GM hasn't marked available.

**Google Calendar**
- **Service-account auth:** Orrey-owned calendar, service account granted `writer` via `acl.insert`. No refresh token = nothing to expire. Community-established, not documented — proved in phase 0.
- **One Orrey calendar** as sync spine; selective subscription via Orrey's own per-campaign ICS feeds from D1.
- **Social calendar is never written to.**

## Send-only messages — what it costs and buys

The things that must stay current are exactly the things driven by clicks, so this is cheaper than it sounds: a tally only changes when someone votes, and that vote is the interaction that re-renders it. Quorum included — the click that crosses the threshold renders the confirmed state.

**Three simplifications:**
1. **The 15-minute interaction token stops mattering.** Every rewrite responds to a *fresh* interaction with a valid token; the channel edit endpoint is never called.
2. **Message rate limits stop being a design concern** — edits were the one route where a sync loop could pile up against an undocumented per-channel limit.
3. **Discord projection narrows to scheduled events.** Messages are fire-and-forget: record the id, never reconcile. Only events keep a fingerprint.

**One cost: posts are snapshots and go stale.** Three mitigations, designed in from the start:
- Every post carries an **as-of line**, so it reads as a record not a live view.
- Every post carries a **Refresh** button — an interaction, therefore allowed, turning a stale snapshot current on demand.
- Anything changing from outside (session moved, jeopardy at T-24h, waitlist promotion) **posts a new notice** rather than mutating an old message.

Posts accumulate (~26 attendance posts a year per campaign, plus notices), so **a thread per session**: attendance, reminders, reschedule notices and the recap all live there, keeping the campaign channel a short list of threads.

## Three surfaces, three jobs

- **Commands** read and initiate; answer ephemerally or post something new; never edit an existing message. Endpoint handles `APPLICATION_COMMAND`, `APPLICATION_COMMAND_AUTOCOMPLETE` (needed so `/reschedule` can name an event), `MESSAGE_COMPONENT`, `MODAL_SUBMIT`.
- **Buttons** respond, and may rewrite their own message.
- **Console** administers — creating campaigns, cadence, rosters. Form-shaped work a slash command models badly, and keeping it out of Discord is what holds the command list to four.

Commands: `/upcoming` (ephemeral agenda across everything — replaces the idea of a pinned board), `/reschedule` (autocomplete an upcoming event → opens a date poll), `/whos-in` (authoritative roster state when a post has gone stale), `/console` (ephemeral login link).

Buttons: attendance post = In / Out / Maybe / Note + *Suggest another day* + *Refresh*; signup post = Take a seat / Waitlist / Out + *Can't make this one — suggest a day*; date poll = multi-select + organiser-only *Canonise*; post-session correction = toggle per person.

## The three stages

1. **Date-finding** (pre-signup). Poll of candidate dates; winners *become* game days. Audience: whole server.
2. **Seating.** Day exists and is announced; people claim a place. Capacity and waitlist. Audience: whole server.
3. **Attendance.** Still coming, and afterwards, did you come? Audience: roster only.

| Event | Stage 1 | Stage 2 | Stage 3 |
|---|---|---|---|
| New campaign | optional (find the slot) | Join the campaign | In? every session |
| Started campaign | skipped | skipped (roster given) | In? every session |
| Single game day | Which Saturday? | Claim 1 of 6 seats | Still in? then who showed |
| Multi game day | Which Saturday? (may win >1) | Coming to the day? | Who showed; tables form on the day |

## Lifecycles

Campaign: `FORMING → RUNNING ⇄ HIATUS → CONCLUDED`. Signup buttons only in FORMING; a closed roster never reopens on its own.

Game day: `PROPOSED → SEATING → LOCKED → PLAYED` (or `CANCELLED`).

Attendance: `intent` (in/out/maybe/null) + `attended` (0/1/null) + `attended_source` (auto/gm).

## Domain model

Domain tables arrive with the phase that uses them — don't add schema ahead of the phase.

- `campaigns` — lifecycle, kind (run/play/**tracked**), recurrence_anchor + interval_weeks, quorum, capacity, max_sessions, **first_session_number**, location_type, channel/role/colour
- `game_days` — **kind (single/multi)**, date, venue, title, host, capacity, game_id (set for single)
- `sessions` — **kind (campaign_session/one_off)**, campaign_id *or* game_day_id, number, starts_at/ends_at, location, state
- `signups` — target_type (campaign-forming *or* game_day), target_id, user_id, state, position, character_name
- `attendance` — intent + attended + attended_source + note (+ optional tables-played on multi days)
- `date_polls` — nullable target, game_id, game_day_kind, **win_rule**, win_threshold, status, opened_by, closes_at, channel/message; `poll_dates` (≤10) with **outcome**; `poll_responses`
- `calendar_links` — session_id, deterministic base32hex gcal_event_id, fingerprint, synced_at, last_error
- `users` — discord_id PK, names as cache, feed_token, dm_state
- `jobs`, `games`, `campaign_members`, `session_logs`, `settings`, `audit_log`

## Architecture

One Worker, three entry points: HTTP (interactions + console + ICS), Queue consumer (outbound writes), Cron (the clock). Drizzle speaks D1 as well as SQLite.

- **Hono on Workers**, console as static assets, same origin. Verify Ed25519 first.
- **D1 + Drizzle.**
- **`jobs` table (D1)** for time-based work — inspectable in a way a queue isn't.
- **Queues** for outbound projection: Google, and Discord **scheduled events** — not messages.
- **Durable Objects** — per guild as rate-limit governor; per session for concurrent clicks.
- **Cron** — minute: drain jobs; hourly: materialise horizon; daily: renew Google watch channels; nightly: full reconcile.
- ICS feeds need an unguessable `feed_token` (calendar clients can't do OAuth).
- **Echo-loop prevention is the app's job** for Google — store a content fingerprint.

## Hermuz: what it proved

~14,500 lines of working TypeScript, last commit 2026-08-20, bot presence already "the Orrery of Worlds." Not an early attempt.

**Carry over (as ideas):** `users.discord_id` as PK; **recurrence as anchor + interval_weeks**, anchor possibly in the past; campaign sessions and one-offs in one table; durable idempotent `jobs` table; inherited location type (maps to Discord `EXTERNAL` vs `VOICE`); single-guild + `settings` key/value. Later: task templates, meal polls.

**Leave behind:** discord.js gateway + privileged intents; SQLite on a Render disk with `numInstances: 1`; separate Netlify SPA + CORS.

**Generalize:** its `surveys` already does candidate dates → canonize → game day → carry available players over. Becomes `date_polls` with nullable target and per-date outcomes.

**Absent entirely:** Google Calendar sync.

Reusing the app inherits guild permissions and **authorship of the scheduled events and roles Hermuz created** (so `CREATE_EVENTS` suffices). The cutover itself is in the phase 0 epic.

## Platform constraints (verified, Sept 2026)

1. **Discord can't own recurrence** — weekly = exactly one weekday; `count`/`end` cannot be set externally.
2. **Discord has no yes/no/maybe** — event user list has no response field.
3. **Google `syncToken` is incompatible with `timeMin`, `q`, `privateExtendedProperty`** — hence our own calendar.

Also: no gateway needed; Discord statuses `COMPLETED`/`CANCELED` are terminal and auto-fire, so **moving a lapsed session means minting a new Discord event**; 100 events/guild; **interaction token lifetime is moot** under send-only; 5 buttons/row so a 10-date poll needs a multi-select; Discord OAuth access tokens ~7 days with **rotating** refresh tokens; bot DMs can't be pre-checked — error `50007` is how you learn, treat as permanent, fall back to channel mention; Google upsert = own base32hex id, `insert` then `409` → `update`; `events.watch` carries **no body**, 7-day TTL, no auto-renew, not 100% reliable; Google attendee `responseStatus` doesn't RSVP for anyone; public ICS refresh ~8–24h in Google; Discord terms require a privacy policy and a delete-my-data path.

## Flagged as unverified

Tracked as `unverified`-labelled issues: the service-account-shared-calendar pattern (phase 0); max TTL for `events.watch` and whether repeated `privateExtendedProperty` filters are AND or OR (phase 7). Not tracked: whether Discord's token endpoints accept PKCE — undocumented and unnecessary for a confidential client.

## Later

Task templates and meal polls (carried over from Hermuz as ideas) — after phase 7, no issue yet.

Hướng dẫn đóng góp

Chưa lập chỉ mục được hướng dẫn đóng góp cho kho mã nguồn này

Đánh giá

Issue này chưa được đánh giá.

Nhận issue mới trong hộp thư của bạn

Bản tóm tắt ngắn những issue GitHub phù hợp với người mới.