oficina / calendar Public

booking: design spec, before the code

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 9a28d7f8f413693ebec53f5ea4b9d9128d51e4fa parent d0ff165
1 file changed, +451 −0
  • docs/superpowers/specs/2026-09-07-booking-design.md +451 −0
diff --git a/docs/superpowers/specs/2026-09-07-booking-design.md b/docs/superpowers/specs/2026-09-07-booking-design.md
new file mode 100644
index 0000000..0871f9b
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-07-booking-design.md
@@ -0,0 +1,451 @@
+# Booking pages — design
+
+**Status:** specified 2026-09-07, before the code. Authored by Claude
+Fable 5.1. Approved in outline by Paul in the session that wrote it
+(three questions, answered: confirm by email link; everything the host
+can see counts as busy; pages are configurable per person). Reviewed by
+codex — see §12 for what it found and what changed.
+
+Asked for: a Calendly / Cal.com style "book time with me" page to share
+with people outside the team. The host sets availability in plain text
+— "mondays between 12–3, tuesdays 12–2, 5-7" — and it just figures it
+out. The booker gets a meeting invitation as ICS, and can reschedule or
+cancel.
+
+This document is binding on the implementation. Where it disagrees with
+AGENTS.md, AGENTS.md is wrong and is fixed in the same commit (§11).
+
+---
+
+## 0. The shape in one paragraph
+
+Each person owns any number of **booking pages**, made in Settings.
+A page has a slug, a duration, an availability sentence, and three knobs
+with defaults. It is public at `/book/{handle}/{slug}`. A stranger picks
+a slot in their own zone, types a name and address, and the slot is
+**held** as a tentative event on the host's personal calendar. One mail
+goes to the booker with one link. Clicking it **confirms**: the event
+becomes confirmed, the booker becomes an attendee, the host is told, and
+the same link is where the booker downloads the ICS, reschedules, or
+cancels. Unclaimed holds expire in thirty minutes and are swept on the
+existing tick.
+
+---
+
+## 1. Rulings made here
+
+| # | Ruling | Rejected | Why |
+|---|---|---|---|
+| B1 | **A booking is confirmed by a link in the booker's mail**, never on submit | Instant confirmation (Calendly's default) | Any typed address would fill a calendar, and the reschedule link would go to whoever that address really is. The link proves the mailbox and is the same one the booker keeps. |
+| B2 | **Everything the host can see is busy** — every calendar they hold a grant on | Personal only; a per-page checklist | Nobody gets booked over the team standup. The page reveals only that a slot is gone, never by what. |
+| B3 | **Pages are per person, any number** | One page per person | "15 min chat" and "60 min call" are different pages with different windows. |
+| B4 | **The public identifier is a chosen plaintext `handle`** | Address in the URL; sealed name | The address must never appear in a URL. AGENTS.md already gives up "who is on the team", so a handle leaks nothing new. |
+| B5 | **Booking mail is the ONE outbound to a non-team address, and only to an address the recipient typed in order to receive it** | Emailing quick-add attendees "while we're at it" | Quick-add attendees stay recorded-not-invited. This is a different act: the booker asked for the mail. |
+| B6 | **The ICS is a tokenised URL, not an attachment** | An attachment | `mail.Sender` is plain text and has no attachment path. And a URL is better: a reschedule updates it in place, an attachment goes stale in the inbox. |
+| B7 | **The availability text has ONE parser and it is Go** | A JS parser for the live pane | The standing rule. The pane is a fetch to the server, exactly as quick add. |
+| B8 | **A held slot is a tentative EVENT**, not a row in a side table | A holds table consulted by the slot query | The range query already knows how to subtract events. Two strangers cannot hold one slot because the first hold is already busy. And the host sees the hold on their calendar, labelled, rather than being surprised by a confirmation. |
+| B9 | **Slots are computed in the HOST's zone and rendered in the booker's** | Computing in the booker's zone | The windows are the host's working hours. "Mondays 12–3" is 12–3 where the host is, whoever is looking. |
+| B10 | **Unknown token → 404**, never 403, never "expired" | Distinguishing expired from wrong | Status codes cannot enumerate bookings. An expired hold's manage page says "this hold has expired" only when the token was RIGHT. |
+
+---
+
+## 2. Data
+
+Four additions. All migrations additive.
+
+### 2.1 `people.handle`
+
+```
+handle TEXT UNIQUE -- nullable; a person with no handle has no pages
+```
+
+Plaintext. Lowercase `[a-z0-9-]{2,32}`, chosen on the settings page,
+validated on save, unique. Changing it changes every public URL and the
+settings page says so before the field.
+
+### 2.2 `booking_pages`
+
+```
+id, person_id (idx), slug, title_sealed, description_sealed,
+duration_min, buffer_min, notice_min, horizon_days,
+availability_text_sealed, active, created_at, updated_at
+UNIQUE (person_id, slug)
+```
+
+- `slug`: `[a-z0-9-]{1,40}`, plaintext, part of the URL.
+- `title_sealed`, `description_sealed`: content. The title is what the
+ booker sees as the page heading and what the event on the host's
+ calendar is called (§5.1).
+- `duration_min`: 5–480. `buffer_min`: 0–120, default 10. `notice_min`:
+ 0–10080, default 120. `horizon_days`: 1–90, default 30.
+- `availability_text_sealed`: the sentence the host typed, verbatim,
+ sealed because it is prose and prose may say anything. It is re-parsed
+ when the host edits it; the parsed windows are §2.3.
+- `active`: an inactive page 404s publicly and lists as "paused" in
+ settings.
+
+### 2.3 `booking_windows`
+
+```
+id, page_id (idx), weekday (0=Sunday..6), start_min, end_min
+```
+
+Plaintext, deliberately. A window is shape-of-time — when the host is
+free — which is already on the leak list in AGENTS.md ("when they are
+busy, for how long, how often"). `start_min < end_min`, both `0..1440`.
+Rewritten wholesale on every save of the page; never edited in place.
+
+### 2.4 `bookings`
+
+```
+id, page_id (idx), event_id (idx, nullable after cancel-and-purge),
+token_hash UNIQUE, state,
+booker_name_sealed, booker_address_sealed, booker_address_fp (idx),
+note_sealed, booker_zone,
+hold_expires_at, confirmed_at, cancelled_at, sequence,
+created_ip_hash, created_at, updated_at
+```
+
+- `token_hash`: SHA-256 of a 32-byte random token, exactly as
+ `HashFeedToken`. The token is 256 bits of entropy so an unkeyed hash is
+ fine here; the ADDRESS is not (below).
+- `state`: `held` → `confirmed` → `cancelled`; or `held` → `expired`.
+- `booker_name_sealed`, `booker_address_sealed`, `note_sealed`: content.
+ Who the team meets with is exactly what the one rule keeps out of the
+ bucket.
+- `booker_address_fp`: `seal.Fingerprint(PurposeAttendeeLookup, addr)`,
+ the same KEYED fingerprint attendees use, so one booker can be found
+ across bookings without their address being a plain digest anyone
+ could reverse from a wordlist.
+- `booker_zone`: IANA name, plaintext — it is a zone, not a place.
+- `sequence`: the ICS `SEQUENCE`, bumped on every reschedule and on
+ cancel.
+- `created_ip_hash`: keyed fingerprint of the requesting IP under a new
+ purpose `PurposeBookingIP`, for the per-IP cap (§7). Never the IP.
+
+---
+
+## 3. The availability text
+
+### 3.1 What it reads
+
+One sentence, comma- or semicolon-separated clauses. Each clause is a
+**day part** followed by one or more **time ranges**:
+
+```
+mondays between 12–3, tuesdays 12–2, 5-7
+mon-wed 9-5
+weekdays 10am to 4pm
+every day 8–6
+saturday 10:30–13:00; sunday 14-16
+```
+
+- **Day part**: a weekday name in any spelling `weekdayAlt` knows,
+ optionally plural (`mondays`), optionally `every`; a weekday RANGE with
+ the same separators as quick add (`mon-wed`, `monday to friday`);
+ `weekdays`; `weekends`; `every day` / `daily`.
+- **Time range**: `reRange` from quick add, verbatim. `12–3` uses the
+ working-day rule: a bare 1–7 is afternoon, so `5-7` is 17:00–19:00 and
+ `12-3` is 12:00–15:00. `9-5` is 09:00–17:00 because 5 → 17:00. A range
+ whose end resolves before its start (`11-3` → 11:00–15:00 is fine;
+ `10pm-2` is not) is **rejected with its text quoted**, never wrapped
+ past midnight: a booking window that crosses midnight is not a thing a
+ host means.
+- **A clause with no day part inherits the previous clause's day.**
+ `tuesdays 12–2, 5-7` is two windows on Tuesday. This is the case in
+ the request and it is the one an implementer will get wrong.
+- Filler words are dropped: `between`, `from`, `and`, `on`, `at`.
+
+### 3.2 What it refuses
+
+Anything it cannot read is **quoted back in the pane as not understood**
+and the page cannot be saved while any clause is. A window the parser
+invented from a clause it half-read would put a stranger in the host's
+calendar at a time the host never offered. Refusal is the safe failure
+here, and it is the opposite of quick add's "leave it in the title"
+because there is no title to leave it in.
+
+### 3.3 Where it lives
+
+`internal/calendar/avail.go` (+ `avail_test.go` in `calendartest`).
+`ParseAvailability(text string) (windows []Window, problems []string)`.
+Pure. It reuses `consumed`, `weekdayAlt`, `rangeSep`, `reRange`,
+`applyMeridiem`, `clock` from the quick-add files — importing them,
+never copying them. If a shared helper needs to move to a common file to
+be shared cleanly, move it in the same commit.
+
+### 3.4 The pane
+
+`POST /settings/booking/parse` returns a fragment listing the windows
+in words — "Monday 12:00–15:00 · Tuesday 12:00–14:00, 17:00–19:00" —
+and any problems, each with the clause quoted. `booking.js` is a
+debounced fetch and nothing else, cloned from `quickadd.js`. Without
+script, saving the form renders the same fragment inline on the
+settings page, with the form re-filled. The pane is the contract: a
+window not in the pane does not exist.
+
+---
+
+## 4. Slots
+
+`FreeSlots(ctx, page, host, from, to time.Time) ([]Slot, error)` in
+`internal/calendar/slots.go`.
+
+1. Clamp `from` to `now + notice_min` and `to` to `now + horizon_days`.
+2. For each calendar day in `[from, to)` **in the host's zone**, using
+ `DayBounds` (a day is 23, 24 or 25 hours): take the windows for that
+ weekday.
+3. Within each window, candidate starts step by `duration_min` from the
+ window's start. A candidate whose end exceeds the window's end is
+ dropped, not clamped.
+4. Fetch busy once for the whole range: `EventsInRange(host)` filtered to
+ `Busy(kind) && status != cancelled`. **Tentative counts as busy** —
+ that is how a hold blocks a slot (B8). All-day events count as busy
+ for their whole day.
+5. Drop any candidate whose `[start - buffer, end + buffer)` overlaps a
+ busy interval. Half-open everywhere.
+6. Return slots as UTC instants plus the host-zone wall clock they were
+ derived from. The public page converts to the booker's zone for
+ display only.
+
+**DST.** Windows are wall-clock in the host's zone. A 09:00–12:00 window
+on the spring-forward Sunday is still 09:00–12:00 local, and it is three
+hours long because neither 09:00 nor 12:00 is in the gap. A window that
+straddles the gap (01:00–03:00) is one hour long that day; on the fold
+day it is three. `DayBounds` and `ToUTC` already encode this and the
+tests pin it (§9).
+
+**Why not a 15-minute grid.** Calendly steps by a grid so a 25-minute
+meeting can start at :15. Stepping by the duration is simpler, gives
+fewer near-identical choices, and matches how people write windows. If
+this is wrong it is a one-line change with a test.
+
+---
+
+## 5. The public flow
+
+All public routes sit in the chi router beside `/feed/{token}.ics`:
+inside `csrf.Protect`, which is app-wide, and OUTSIDE the signed-in
+group. `csrf.Protect` checks origin on state-changing methods only, so
+GETs pass, and a stranger's browser posting our own form sends
+`Sec-Fetch-Site: same-origin`, so those pass too; a cross-site POST is
+refused, which is correct.
+
+### 5.1 `GET /book/{handle}/{slug}`
+
+The page: title, description, duration, the host's first name if they
+set one (sealed name, opened server-side; never the address). A zone
+`<select>` defaulting to the host's zone. A list of the next days that
+have any free slot, as links to `?day=YYYY-MM-DD&zone=…`. With `day`
+set, that day's slots as buttons — a `<form method=post>` each, carrying
+`start` (UTC unix) and `zone`. No JS required; `booking.js` may
+preselect the zone from `Intl.DateTimeFormat().resolvedOptions().timeZone`
+and nothing else.
+
+Inactive page, unknown handle, unknown slug: 404, same body, same
+timing as far as we control it.
+
+### 5.2 `POST /book/{handle}/{slug}` — hold
+
+Fields: `start`, `zone`, `name`, `address`, `note` (optional, 2000 max).
+
+1. Recompute free slots for that day. If `start` is not one of them:
+ re-render with "That time has just gone" and the day's remaining
+ slots. Never trust the posted start.
+2. Caps (§7). Over cap: 429 with a plain page.
+3. In one transaction:
+ - Create the EVENT on the host's **personal** calendar (ruling 8 in
+ AGENTS.md: personal by default). Title `"{page title} — {name}"`,
+ sealed. Kind meeting, **status tentative**, start/end from the
+ slot, TZ the host's zone. Notes sealed: the booker's note.
+ - Create the booking row, state `held`, `hold_expires_at = now +
+ 30m`, token minted and hashed.
+4. Mail the booker (§6.1). If the send fails, roll back both rows and
+ render "We could not send to that address" — a hold nobody can
+ confirm is a slot stolen for thirty minutes for nothing.
+5. Render "Check your mail" with the address echoed back (it is theirs;
+ they just typed it) and the expiry time in their zone.
+
+### 5.3 `GET /book/m/{token}` — confirm and manage
+
+Look up by `HashFeedToken(token)`. Not found → 404 (B10).
+
+- `held`, not expired: **confirm it**. This is a GET that mutates, and
+ that is deliberate: it is the link in the mail and it must work from
+ any mail client, including ones that prefetch. Confirming twice is
+ idempotent. Set `confirmed_at`, event status → confirmed, create the
+ attendee row (`SetAttendees`), mail the host (§6.2), then render the
+ manage page.
+- `held`, expired: render "This hold expired at …; book again" with a
+ link back to the page. The sweep will tidy the row.
+- `confirmed`: render the manage page: what, when (in `booker_zone`),
+ where (nothing; there is no location), an **Add to calendar** link
+ to `.ics`, a **Reschedule** link, a **Cancel** form.
+- `cancelled` / `expired`: say so, link back to the page.
+
+### 5.4 `GET /book/m/{token}.ics`
+
+`text/calendar`, `Content-Disposition: attachment; filename=…`. One
+VEVENT: `METHOD:REQUEST` while confirmed, `METHOD:CANCEL` and
+`STATUS:CANCELLED` once cancelled. `ORGANIZER;CN=host:mailto:host` —
+the host's address IS in the ICS, because the booker needs to know who
+they are meeting and their client needs it to file replies; this is the
+same information the confirmation mail already carries in its From.
+`ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:booker`. `SEQUENCE` from
+the row. `URL:` the manage link. `UID` stable across reschedules. Built
+with `WriteICS`-adjacent helpers, never a second folding implementation.
+
+### 5.5 `GET/POST /book/m/{token}/reschedule`
+
+GET: the slot picker from §5.1 for the same page, with the current
+booking's own slot **excluded from busy** (otherwise the booker can
+never pick the slot next to their own). POST `start`: validate as in
+§5.2 step 1, update the event's start/end (explicit column allowlist,
+never a model bind), bump `sequence`, mail both parties (§6.3), render
+the manage page.
+
+### 5.6 `POST /book/m/{token}/cancel`
+
+Event status → cancelled, `cancelled_at`, `sequence++`, mail both
+(§6.4). The event stays on the host's calendar labelled Cancelled,
+exactly as any other cancelled event does — colour never carries meaning
+alone.
+
+---
+
+## 6. Mail
+
+All plain text through `mail.Sender`. Every body is a template in
+`templates/mail/`, so the wording is reviewable as prose. No booker
+content in any log line.
+
+| | To | Says |
+|---|---|---|
+| 6.1 hold | booker | "You asked to book {title} with {host} on {when in booker zone}. Confirm it here: {link}. The hold expires at {expiry}." |
+| 6.2 confirmed | host | "{name} ({address}) booked {title}, {when in host zone}. Note: {note}. It is on your calendar: {event link}." |
+| 6.3 rescheduled | booker AND host | The new time, both zones where they differ, the manage link (booker) or event link (host). |
+| 6.4 cancelled | booker AND host | Who cancelled (always the booker, in this version), what, when it was. |
+
+The host's mail includes the booker's address and note because the host
+is on the team and that is delivery, not storage — the same argument
+AGENTS.md makes for reminder mail. The booker's mail never includes
+another person's address except the host's, which is the ORGANIZER.
+
+---
+
+## 7. Abuse
+
+A public POST that sends mail and writes rows needs limits, and the
+limits must not need a new dependency.
+
+- **Per-IP**: at most 5 open holds (`state = held`, not expired) per
+ `created_ip_hash`. The IP comes from the platform's forwarding header,
+ which rastrillo already resolves for the CSRF origin check; use the
+ same accessor.
+- **Per-page**: at most 20 open holds. A popular host with 20 strangers
+ mid-confirmation is unlikely; a script holding every slot on a page is
+ not.
+- **Per-address**: at most 2 open holds per `booker_address_fp`.
+- Over any cap: 429, "Too many bookings in progress. Try again in half
+ an hour." No cap is per-day: expired holds free their count.
+- A stranger can learn the host's free/busy by walking the page. The
+ estate already gives that up (§1 of AGENTS.md); the page never says
+ WHAT is busy.
+
+---
+
+## 8. The sweep
+
+`hold_expires_at` joins `due_at` in `NextDueAt`: the next due instant is
+the earlier of the next reminder and the next hold expiry. `drain` grows
+a step: every `held` booking past expiry → state `expired`, its event
+**deleted** (not cancelled: a hold that was never confirmed was never a
+meeting, and a calendar full of cancelled ghosts from bots is a worse
+calendar). Inside the request, like the reminders; idempotent; at least
+once.
+
+---
+
+## 9. Testing
+
+Enforced, not remembered.
+
+- **Parser table** (`avail_test.go`): every example in §3.1, the
+ inheritance case, the midnight refusal, plural and singular, `every`,
+ `weekdays`, `weekends`, each separator, the en dash, a clause with
+ three ranges, two problems in one sentence both reported.
+- **Slots** (`slots_test.go`): a Monday with a 12–3 window and a 60-min
+ page gives 12:00, 13:00, 14:00. A busy 13:00–13:30 with no buffer
+ removes 13:00 only. With a 10-min buffer, every slot is padded to
+ `[start-10m, end+10m)` before the overlap test: 12:00 becomes
+ `[11:50, 13:10)` and now overlaps the busy block, so 12:00 AND 13:00
+ go and only 14:00 (`[13:50, 15:10)`) survives. A tentative event
+ blocks. A cancelled one does not. A note-kind event does not. An
+ all-day event blocks the day. `notice_min` hides the next two hours.
+ `horizon_days` ends the list. The spring-forward Sunday
+ (`Europe/Dublin`, 29 March 2026) and the fold Sunday (25 October
+ 2026) pinned to real unix numbers, as `clock_test.go` does.
+- **Flow** (`booking_http_test.go`): through the router with
+ `pickyMailer`. Hold creates a tentative event and mails one link;
+ the link confirms, creates the attendee, mails the host; confirming
+ twice is idempotent; a second hold on the same slot is refused with
+ "just gone"; reschedule excludes own slot from busy and bumps
+ `SEQUENCE`; cancel produces `METHOD:CANCEL`; a wrong token 404s and
+ the body is byte-identical to an unknown route's 404; an expired
+ token says expired; the ICS round-trips through `ParseICS`.
+- **Caps**: the sixth hold from one IP 429s; expiring one frees it.
+- **Sweep**: `tick` after expiry deletes the event and the slot is free
+ again; `NextDueAt` returns the hold's expiry when it is sooner than
+ any reminder.
+- **The one rule**: `TestTheDatabaseNeverHoldsAPlaintextTitle` gains a
+ booking fixture — booker name, address, note, page title,
+ availability text — and greps the raw bytes for each. The token is
+ asserted absent too.
+- **Logs**: the picky mailer's failure path is exercised and the log
+ buffer is grepped for the booker's address.
+- **Browser** (`make browser`, sixth and seventh passes): a stranger
+ with JavaScript OFF books a slot end to end, reads "check your mail",
+ and the recorded mail's link confirms. With JS on, the settings pane
+ fills in from the availability text.
+
+---
+
+## 10. What is NOT in this version, said so nobody reads it in
+
+- **Group / round-robin pages.** One host per page.
+- **Booker questions / custom fields.** Name, address, one note.
+- **Host-initiated reschedule or cancel from the manage page.** The host
+ edits or cancels the event like any other; the booker is NOT mailed
+ when they do. This is a real gap and the event page says so beside
+ the booking badge: "Changing this here does not tell {name}."
+- **Payments, video links, reminders to the booker.** The host's own
+ reminders apply to the event as to any event.
+- **Locale.** English strings, like the rest of the app.
+- **Cancelling a confirmed booking purges nothing.** The row and the
+ cancelled event stay until a later retention decision.
+
+---
+
+## 11. Changes to AGENTS.md in the same commit
+
+- Settled decisions: add B1, B2, B4, B5, B8 as rows.
+- Mechanical rules → Frontend: after "Attendees are recorded, never
+ invited", add: **"Except a BOOKER, who typed their address to be
+ mailed. Booking mail is the one outbound to a non-team address."**
+- Mechanical rules → Data: **"Booker name, address and note are sealed;
+ the address has the keyed fingerprint. Booking windows are plaintext:
+ they are shape-of-time."**
+- Mechanical rules → Reminders → rename the section **Ticks**, and add:
+ **"`NextDue` is the earlier of the next reminder and the next hold
+ expiry. Add a third kind of scheduled work here, not in a goroutine."**
+- The one rule's "what this does NOT cover": add **"5. A booking page is
+ a public free/busy oracle for that host. Stepping through it learns
+ the host's free time, which the estate already gives up."**
+
+---
+
+## 12. Review
+
+Reviewed by `codex-cli 0.153.0` on 2026-09-07. Findings and
+dispositions are recorded below after the review runs; this section is
+filled in the same commit that acts on them.