# 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). **Revision
9.** Reviewed eight times by GPT-6 (`gpt-6-astra` on the one run that
exposed its identifier) through `codex-cli 0.153.0`; all eight returned
REWRITE, each round narrower than the last. Rounds 7 and 8 found
nothing in the booking design itself: every finding was a pre-existing
bug in the recurrence engine the design depends on, and the spec owns
them (§4.1). The spec rounds end here; the next review is of the code. §12 records every finding
and disposition, including the two scoped rather than solved.

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 title, a duration, an availability sentence, and three
knobs with defaults. It is public at `/book/{handle}/{code}`, where the
code is generated. 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.
The link shows a page with one button; pressing it **confirms**: the
event becomes confirmed, the booker becomes an attendee, the host is
told, and the same page is where the booker downloads the ICS,
reschedules, or cancels. A hold that is not confirmed expires — after
thirty minutes, or when the meeting would have started, whichever is
first — and is swept on the existing tick.

---

## 1. Rulings made here

| # | Ruling | Rejected | Why |
|---|---|---|---|
| B1 | **A booking is confirmed by a button on the page the mailed link opens** — a same-origin POST — never on submit and never on GET | Instant confirmation; confirming on the GET | A GET that confirms is confirmed by every link-scanning mail gateway (r1 f2). The mailed link is a request; the button is the consent. |
| B2 | **Everything the host can see is busy** | Personal only | Nobody gets booked over the team standup. |
| B3 | **Pages are per person, any number** | One page | "15 min chat" and "60 min call" differ. |
| B4 | **Public identifier: chosen plaintext `handle` + GENERATED page code** | A chosen slug | A slug is prose joined to events (r1 f1). |
| 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 | The booker asked for the mail. |
| B6 | **The ICS is a download at a tokenised URL, not an attachment, and carries NO management link** | Attachment; `URL:` | `mail.Sender` is plain text; a capability inside an ICS syncs everywhere (r1 f6). |
| B7 | **ONE availability parser, in Go** | JS parser | The standing rule. |
| B8 | **A held slot is a tentative EVENT** | A holds table | The range query already subtracts events. |
| B9 | **Slots are computed in the HOST's zone and rendered in the booker's** | Booker's zone | The windows are the host's hours. |
| B10 | **Unknown token → 404**, never 403 or "expired" | Distinguishing | No enumeration. |
| B11 | **The EVENT's `sequence` is the one revision counter**, bumped as `sequence = sequence + 1`, and every booking-side write names the revision it expects | A booking sequence; stale `+1` | Drift (r1 f11); lost bumps (r2 A11); replayed forms (r2 A10). |
| B12 | **Hold and reschedule serialise on a process-wide mutex AND take SQLite's write lock before the final busy read, inside the transaction that inserts** | The mutex alone; a busy read before `BEGIN` | The mutex orders booking requests against each other; it does not order them against an ordinary `CreateEvent` by the host, which can land between a busy read and the insert (r7 f2). A no-op write at the top of the transaction acquires the RESERVED lock, so every other writer waits until the hold commits. |
| B26 | **Reschedule enumerates the event's reminder RULES and upserts a queue row for each from the new time** | Rebuilding the queue rows that exist | A reminder that already fired has no queue row; rebuilding "every row" schedules nothing for tomorrow's moved meeting (r7 f3). |
| B13 | **Mail is a QUEUE TABLE, one row per (booking, kind, recipient, revision), claimed with an identity and a lease, acknowledged conditionally on that identity** | A marker column; anonymous claims | One marker loses a cancellation mid-send (r2 B2); an old worker must not acknowledge a newer claim (r3 f6). |
| B14 | **The token is 32 bytes from `crypto/rand`, hex-encoded, stored SEALED as well as hashed** | Hash only; derived tokens | The queue must rebuild the link (r2 B1); a derived token is a guessable one (r3 f11). |
| B15 | **A hold expires at the EARLIER of thirty minutes and the meeting's start; confirmation requires `now < start`** | Thirty minutes flat | A five-minute meeting confirmed after it ended (r2 B5). |
| B16 | **No sender error is "definite."** Nothing is freed because a send failed | Marking holds failed | `smtp.SendMail` can fail after acceptance (r2 A12). |
| B17 | **A slot is the same length on the wall clock and the elapsed clock, or it is not offered** | Round-tripping the start only | The fold makes a two-hour hour (r2 A13). |
| B18 | **A booked event's SCHEDULING is owned by the booking flow.** The host's form may change title, notes, location, and status to cancelled; it may NOT change start, end, tz, all_day, kind or rrule, and may not revive a terminal booking. `UpdateEvent` refuses, naming the booking | Invariant checks on host-supplied times | Invariants were not enough: a stale form re-saved with an old 09:00 passes every invariant and moves a rescheduled meeting back (r4 A5); a kind change to `note` makes a confirmed booking non-busy (r4 B1). Locking the fields closes both. |
| B19 | **Mail says the CURRENT state**, worded for it, and a transition deletes every unsent row the new state makes false | Historical snapshots | Snapshots need a sealed body per row and per-booking serialised delivery (r3 f4); current-state wording needs neither and is what a reader wants to know. What has already reached the sender cannot be recalled, and the spec says so. |
| B20 | **The booker sees the page's title and description AS THEY WERE at hold time**, snapshotted sealed onto the booking | Live page text | Live text changes the ICS without changing its `SEQUENCE` (r3 f10). |
| B21 | **Reschedule writes `tz` = the host's current zone atomically with both local strings and both UTC columns** | Leaving `tz` as it was | A host who moved zones would get Tokyo strings on a Dublin record (r3 f2). |
| B22 | **Both queues carry a random `claim_id`; every completion mutation — ack, fail, defer, give-up, advance — is conditional on the `claim_id` the worker holds and is built from the ROW THE CLAIM RETURNED, never from a candidate read; `ErrMailBusy` defers without counting an attempt. Reminders keep their semantic `due_at` and retry through `retry_at`; booking mail's `due_at` is retry eligibility and nothing else** | Claim by ID or by `claimed_at`; retry time written into `due_at` | A crash after a claim advertised work nobody could take (r4 A6); four stalled sockets burned every other message's attempts (r4 B4); a reminder finishing during a reschedule deleted the replacement (r4 B3); a whole-second `claimed_at` is not an identity (r5 f1); a reminder's `due_at` IS the occurrence minus the offset, so writing a retry time into it moved the meeting (r5 f3). |
| B24 | **Reschedule repairs the event's reminder queue rows INSIDE its transaction** | `RescheduleReminder` after commit | A crash between the two leaves a queue row that sends a reminder for a time the meeting no longer has (r5 f2). |
| B25 | **A booked event's form carries its scheduling snapshot as hidden fields — including the event's own `tz` — with the visible controls disabled** | Disabled controls alone | Disabled controls submit nothing, so a title-only edit 422s; and the reader's `TZ = p.Zone` fails the comparison after a profile zone change (r5 f4). |
| B23 | **Availability FAILS CLOSED**: if any busy series in the window could not be expanded to completion — truncated OR carrying a rule this app cannot parse — the page is 503, never "free" | Trusting `Expand`'s slice | `Expand` stops at 100,000 steps and returns an ordinary slice; a forever-daily rule from 1700 contributes no busy time to 2026 (r4 B5). |

---

## 2. Data

All migrations additive, generated.

### 2.1 `people.handle`

Nullable, unique, plaintext, `[a-z0-9-]{2,32}`, lowercased on save.
Reserved: `m`, `new`, `static`. Changing it changes every public URL
and the settings page says so.

### 2.2 `booking_pages`

```
id, person_id (idx), code, title_sealed, description_sealed,
duration_min, buffer_min, notice_min, horizon_days,
availability_text_sealed, published_at (nullable), created_at, updated_at
UNIQUE (person_id, code)
```

- `code`: eight of `[a-z0-9]`, generated, immutable, plaintext. Not a
  secret — it is in a shared URL — and distinct from the management
  token (§2.4).
- `title_sealed` ≤ **100 bytes**; `description_sealed` ≤ 1000 bytes.
  Byte limits, because the event validator's are bytes (r2 B6).
- `duration_min` 5–480; `buffer_min` 0–120 (default 10); `notice_min`
  0–10080 (default 120) with a **floor of 10** at slot time (B15);
  `horizon_days` 1–90 (default 30).
- `published_at`: NULL is unpublished → 404. Publishing is explicit
  (§7.4).

### 2.3 `booking_windows`

```
id, page_id (idx), weekday (0=Sunday..6), start_min, end_min
```

Plaintext (shape-of-time). `0 <= start_min < end_min <= 1440`.
Rewritten wholesale on save, **merged** first.

### 2.4 `bookings`

```
id, page_id (idx), event_id (idx), token_hash UNIQUE, token_sealed, state (idx),
title_sealed, description_sealed,
booker_name_sealed, booker_address_sealed, booker_address_fp (idx),
note_sealed, booker_zone,
hold_expires_at (idx), confirmed_at, cancelled_at, cancelled_by,
ip_fp (idx, nullable), ip_forget_at (idx), reschedules,
created_at, updated_at
```

- **Token (B14):** 32 bytes from `crypto/rand`, `hex.EncodeToString`
  (64 characters, URL-safe as is). `token_hash = HashFeedToken(token)`
  is the lookup; `token_sealed` is the token under
  `PurposeBookingToken`, opened only to render a mail. A `UNIQUE`
  collision on insert regenerates once and then errors.
- `state`: `held` → `confirmed` → `cancelled`, or `held` → `expired`.
- `title_sealed`, `description_sealed`: **the page's text at hold time**
  (B20). Every public surface reads these, never the page's.
- `booker_zone`: one of `CommonZones` or the host's own zone.
- `cancelled_by`: `''`, `booker`, `host`.
- `ip_fp`: `Fingerprint(PurposeBookingIP, ip)`; `ip_forget_at =
  created_at + 24h`; nulled by the sweep at or after it; the earliest
  outstanding `ip_forget_at` is a `NextDueAt` source.
- `reschedules`: capped at `MaxReschedules = 5`.

### 2.5 `booking_mails`

```
id, booking_id (idx), kind, recipient, revision,
due_at (idx), attempts, claimed_at, claim_id, sent_at, created_at
UNIQUE (booking_id, kind, recipient, revision)
```

- `kind` ∈ {`hold`, `confirmed`, `rescheduled`, `cancelled`};
  `recipient` ∈ {`booker`, `host`}; `revision` = the event's
  `sequence` when the row was made.
- Rows are created inside the transition's transaction with `due_at =
  now`. For booking mail `due_at` means ONLY "eligible to claim from";
  it carries no other meaning, which is why a claim may advance it
  (contrast reminders, §6). The handler that made them tries once,
  immediately (§6); the drain owns retries.
- **Claim (B13, B22):** `UPDATE … SET claimed_at = now, claim_id =
  <random>, due_at = now + claimLease WHERE id = ? AND sent_at IS NULL
  AND due_at <= now`; the claimer keeps `claim_id`. Advancing `due_at`
  on claim is what makes `due_at` the ONE claimable instant: a crash
  after a claim leaves a row that becomes due again when the lease
  ends, and `NextDueAt` reads nothing but `due_at` (r4 A6).
  **Acknowledge:** `SET sent_at = now WHERE id = ? AND claim_id = ?`.
  **Fail (a real send error):** `SET attempts = attempts + 1 WHERE id =
  ? AND claim_id = ?` — `due_at` already points at the lease end.
  **Defer (`ErrMailBusy`, no SMTP call was made):** `SET due_at = now +
  30s, claimed_at = NULL WHERE id = ? AND claim_id = ?`, attempts
  untouched (r4 B4). At `MaxReminderAttempts`, `sent_at = -1` (gave up)
  and a log line says `stage=booking.mail code=gave_up`.
- **Supersession (B19):** when a transition makes a booking
  `cancelled` or `expired`, every unsent row for that booking is
  deleted in the same transaction and the `cancelled` rows are queued
  (none for `expired`). When a reschedule succeeds, unsent
  `rescheduled` rows are deleted before the new ones are queued. A
  `confirmed` row survives a reschedule and its body says the current
  time. A row that has already been claimed and handed to the sender
  cannot be recalled; a booker may therefore receive "confirmed" a
  minute before "cancelled" if both raced. Stated, not hidden.
- No content is stored. Bodies render at send time from the booking
  (snapshotted text, sealed booker fields, opened token), the event's
  allowlisted columns (§5.5) and the booking's CURRENT state.

### 2.6 Ownership

Every settings read or write of a page is `WHERE id = ? AND person_id
= ?` → `ErrNotFound` → 404. `PublicBookingPage(handle, code)` is the
only unscoped reader and requires `published_at IS NOT NULL`.

**Terms.** A booking's times and duration are the event's; a page edit
never touches an existing event; a reschedule keeps the event's
duration. A booking's title and description are its own snapshot
(B20). The page's live text affects only future bookings.

### 2.7 The client IP

The instance listens on a unix socket only the platform edge reaches;
the edge calls `httputil.ProxyRequest.SetXForwarded`, which REPLACES
`X-Forwarded-For` with the dialling client (`platform/internal/edge/
homemap.go setForwarded`). **The client IP is the LAST element of
`X-Forwarded-For`**; absent the header, the host of `RemoteAddr`.
`net.ParseIP(...).String()`; unparseable → `"unknown"`. Never logged.

---

## 3. The availability text

### 3.1 What it reads

Clauses split on `,` and `;`. Each clause is a **day part** then one or
more **time ranges**; a clause with no day part inherits the previous
clause's days; a first clause with none is a problem.

```
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 in any `weekdayAlt` spelling, optionally
  plural, optionally `every`; a weekday range (`mon-wed`, `monday to
  friday`) walking forward modulo 7; `weekdays`; `weekends`; `every
  day` / `daily` / `all days`.
- **Time range**: two clocks joined by `-`, `–`, `—`, `to`, `until`,
  `till`. A clock is `H`, `H:MM`, `Ham`, `H:MMpm`. **Raw components are
  validated first, with no clamping**: minute `0..59`; with a meridiem
  the hour is `1..12`; without, `0..24`, and `24` only as `24:00` at
  the END. **Meridiem:** an explicit `am`/`pm` on either end applies to
  both unless the other says otherwise; with none, the working-day rule
  applies to the HOUR COMPONENT regardless of minutes: 1–7 → afternoon
  (`5-7` → 17:00–19:00; `12:30-2:30` → 12:30–14:30), 8–12 and 0, 13–24
  as written. End not after start → problem, the range quoted.
- Filler dropped: `between`, `from`, `and`, `on`, `at`.

### 3.2 What it refuses

Every unconsumed token is a problem, quoted. All problems are
reported. The page cannot be saved while any exists.

### 3.3 Where it lives

`internal/calendar/avail.go` + `avail_describe.go` (not `avail_windows.go`:
Go reads a `_windows.go` suffix as a GOOS constraint and never compiles
the file on Linux — found when the first build could not see it). `ParseAvailability
(text) (windows []Window, problems []string)`, pure, merged, sorted.
Shares `consumed`, `weekdayAlt`, `weekdays`, `rangeSep` with quick add;
does NOT call `clock`/`applyMeridiem`.

### 3.4 The pane

`POST /settings/booking/parse` → fragment: windows in words plus every
problem. `booking.js` is a debounced fetch cloned from `quickadd.js`.
Scriptless: a save with problems re-renders at 422 with the fragment.

---

## 4. Slots

`ComputeSlots(rule, busy, now, from, to)` is pure (`slots.go`);
`FreeSlots` fetches busy and calls it (`store_slots.go`).

0. **Preconditions.** `ComputeSlots` returns an error, never a list,
   when `DurationMin < 1` or `> 480`, when `Zone()` fails, or when a
   window has `EndMin <= StartMin` (r3 f1). Callers surface the error;
   nothing loops.
1. **Bounds.** `earliest = now + max(notice_min, 10m)`; `latest =
   now.In(hostLoc).AddDate(0,0,horizon_days)`. Offered iff `start >=
   earliest && start >= from && start < to && end <= latest`. `to`
   bounds the START. Every inequality is per candidate.
2. **Days.** Host-zone calendar days from `from` to `to`, by
   `AddDate(0,0,1)` on a local midnight.
3. **Candidates.** For each window that weekday, `m` from `start_min`
   by `duration_min` while `m + duration_min <= end_min`: `start =
   time.Date(y, mo, d, 0, m, 0, 0, hostLoc)`, `end = start + duration`
   (elapsed). Kept only if all four hold (B17): `start`'s wall minute
   is `m`; `end`'s wall minute is `m + duration_min` on the same local
   date (or `1440`); `ToUTC(FormatLocal(start)) == start.Unix()`;
   `ToUTC(FormatLocal(end)) == end.Unix()`.
4. **Busy.** Fetched once over `[from - 1 day, to + 1 day)` from
   `EventsInRange(host)` — **after the two fixes in §4.1** — using
   `OccStartUTC`/`OccEndUTC`: keep `Busy(kind) && status != cancelled`;
   tentative is busy; the event being rescheduled is excluded by id; a
   tentative event whose booking is `held` with `hold_expires_at <=
   now` is NOT busy. **All-day**: the occurrence's dates are recovered
   as `time.Unix(OccStartUTC).In(eventZoneOrUTC)` through
   `time.Unix(OccEndUTC-1).In(…)`, then each date's `DayBounds` in the
   HOST's zone. All-day notes are not busy.
5. **Buffer.** Drop if `[start - buffer, end + buffer)` overlaps busy.
6. Return ascending `Slot{StartUTC, EndUTC, Local}`; the page groups
   by the booker-zone date of START.

### 4.1 Two fixes to the range query it depends on

Both are calendar bugs, visible in every view, owned here because busy
time must be what the calendar shows.

- **Moved override of a finished series (r2 B3).** `EventsInRange`
  drops an override whose master the query did not select. Fix:
  override rows returned by `rangeRows` contribute their `SeriesID` to
  the set `overridesFor` loads, so pass 2 places them.
- **Long final occurrence (r3 f7, r4 A7).** `rangeRows` excludes a
  master when `until_utc < from`, but `UNTIL` bounds the last
  occurrence's START, not its end, and an occurrence's elapsed length
  is not the master's across a DST day. Fix: the exclusion becomes
  `until_utc + (end_utc - start_utc) + 90000 < from` — the master's
  duration plus a 25-hour day of slack, which is conservative for any
  single transition. A day's extra expansion is cheap; a lost
  occurrence is not.
- **Multi-day span across spring DST (r4 B2).** `daysBetween` divides
  elapsed hours by 24, so a 28 March 09:00 → 30 March 09:00 event in
  Dublin (47 h) spans "one day" and every later occurrence ends a day
  early. Fix: the day span is the difference of CIVIL dates
  (`time.Date(y,m,d,0,0,0,0,time.UTC)` for each end, then `/24h`),
  independent of the zone's offsets.
- **Recurrence drift across the spring gap (r7 f1).** `advance` steps
  from each occurrence's RESOLVED time with `AddDate`, so a daily
  01:30 meeting whose 29 March instance resolves to 02:30 (the gap)
  starts at 02:30 on every day after — the record says 01:30 and the
  calendar shows 02:30 forever. Fix: advancement carries the ORIGINAL
  civil clock (the master's hour and minute) onto each new civil date,
  `time.Date(y, m, d, protoH, protoM, 0, 0, loc)`, independently of how
  the previous occurrence resolved; the gap day alone shows the
  resolved time. Regression: the daily 01:30 series shows 01:30 on 30
  March and on a September day, and a September booking page does not
  offer 01:30–02:00 over it.
- **Fortnightly `BYDAY` skips a weekday (r8 f1).** `advance` for a
  weekly rule with an interval searches only the next seven days and
  then jumps a whole interval, so `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE`
  from Wednesday 2 September generates 16 September and never 14
  September. Fix: weekly advancement enumerates every selected weekday
  within each ELIGIBLE civil week, anchored to the master's week
  (weeks counted from the master's week start, eligible when the
  difference is a multiple of the interval), in order. Regression: 14
  and 16 September both show and neither is bookable.
- **A parsed rule the expander cannot execute (r8 f2).** `ParseRule`
  accepts `FREQ=MONTHLY;BYDAY=MO` (every Monday of the month) and the
  expander ignores `ByDay` for monthly rules, falling back to the
  master's day of month. Fix: `ParseRule` validates COMBINATIONS —
  `ByDay` (non-ordinal) only with `FREQ=WEEKLY`; `Ordinal`/`MonthDay`
  only with `FREQ=MONTHLY`; nothing but `INTERVAL`/`COUNT`/`UNTIL` with
  `DAILY` and `YEARLY` — and returns `ErrForeignRule` otherwise. The
  rule is still stored verbatim (ruling 4), the calendar shows its
  first occurrence as it does for any foreign rule, and the strict
  reader fails closed on it. Regression: the import above → the day
  page shows one occurrence, `EventsInRangeStrict` errs, and the public
  page 503s.
- **Expansion that did not finish, or could not start (r4 B5, r6 f1;
  B23).** `Expand` stops at 100,000 steps and returns an ordinary
  slice; and `EventsInRange` renders a master whose rule `ParseRule`
  rejects as its first occurrence only, never expanding it. Both are
  right for a calendar view and wrong for deciding free time. `Expand`
  gains a companion that also reports completeness, `ExpandComplete
  (...) ([]Occurrence, bool)`; `Expand` calls it and drops the flag.
  `EventsInRange` gains a strict twin, `EventsInRangeStrict`, that
  returns `ErrIncompleteExpansion` when any BUSY master in the window
  (`Busy(kind)`, not cancelled) was truncated OR has a rule `ParseRule`
  cannot read. **Busy uses the strict twin** and a public handler
  answers 503 with `Retry-After: 60` when it errs; views keep the
  tolerant one, and the stored rule stays verbatim (ruling 4).

Regression tests in `occurrence_test.go`: a daily series ending in
three days with its last occurrence moved ten days out shows on that
day; a series whose final occurrence spans four days shows on its
third; a weekly two-day Dublin event created 28 March 09:00 – 30 March
09:00 still occupies 30 March on its first AND a later occurrence; a
daily rule with `DTSTART` in 1700 makes `EventsInRangeStrict` return
`ErrIncompleteExpansion` for a 2026 window while `EventsInRange` still
answers.

### 4.2 Cost

`?day=` outside `[today, today + horizon_days]` in the booker's zone →
404. The day list is ONE `EventsInRange` over the horizon; a day page
ONE over that day ±1. Every public route passes the gate in §7.2. A
process-wide semaphore of **4** concurrent public slot computations
guards `FreeSlots` from public handlers; when it is full the response
is **503** with `Retry-After: 5`, and never a page computed from
truncated busy data (r3 f8).

---

## 5. The public flow

Public routes sit beside `/feed/{token}.ics`: inside `csrf.Protect`,
outside the session group, each wrapped by the gate (§7.2), which also
sets `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. The
token is in the path and WILL be in the access log — the feed's own
residual, stated.

### 5.1 `GET /book/{handle}/{code}`

`PublicBookingPage` → 404 byte-identical to the router's own. The page:
title, description, duration, host's first name (opened `NameSealed`)
or handle, a zone `<select>` of `CommonZones` plus the host's zone;
`?zone=` outside that set → the host's zone.

No `?day=`: the next days with any free slot, as links carrying `day`
(booker-zone date) and `zone`. With a valid `day`: that day's slots as
`<form method=post>`s carrying hidden `start` (UTC unix) and `zone`.
Pressing one posts WITHOUT `name`; the handler re-verifies the slot and
renders the page with it fixed and the details form (`name`, `address`,
`note`) beneath, `start`/`zone` hidden. Two POSTs, no script.
`booking.js` may only preselect the zone.

### 5.2 `POST /book/{handle}/{code}` with `name` — hold

Body ≤ 16 KiB. `name` 1–60 runes AND ≤ 80 bytes; `address` a full
`reAddress` match normalising to one address; `note` ≤ 1000 runes AND
≤ 4000 bytes. Over → 422.

Inside `holdMu` (B12): §7.1 caps → 429; `FreeSlots` for that day for
the RESPONSE (so a taken slot can be reported without a write lock);
`start` present → else 409 "That time has just gone". Then ONE
transaction that (1) acquires the write lock first with a no-op write
(`UPDATE booking_pages SET updated_at = updated_at WHERE id = ?`) —
SQLite begins deferred, and a read inside a deferred transaction does
not stop another writer committing under it (r7 f2); (2) re-runs the
strict busy read and the slot check for that day THROUGH the
transaction, and rolls back with `ErrSlotTaken` if the slot has gone;
(3) inserts the tentative event (title `"{page title} — {name}"`,
notes = the note, kind meeting, `tz` = host zone, local strings from
the instant), the booking (`held`, `hold_expires_at = min(now + 30m,
start)`, token hashed and sealed, title and description snapshotted,
`ip_fp`, `ip_forget_at`), and `booking_mails(hold, booker, 0)`.

Outside the mutex: try the row once (§6). **Whatever happens, the hold
stands** (B16). Response 200 "Check your mail" with the address and
the expiry in the booker's zone; if the try errored, plus "If nothing
arrives in a few minutes, the hold lapses at {expiry} and you can try
again."

### 5.3 `GET /book/m/{token}`

`BookingByToken` → 404. Reads the event ROW for `status`, `deleted_at`,
times, `sequence`. Then:

- `held`, `now < hold_expires_at`: what and when; ONE form → `POST
  …/confirm`, "Confirm this booking"; "Didn't ask for this? Do
  nothing; it expires at …". Nothing changes on GET.
- `held` past expiry, or `expired`: "This hold expired at …".
- `confirmed`, event not cancelled and not deleted: manage (§5.5).
- otherwise: "cancelled", by whom (`cancelled_by`, or "the host" when
  the event is cancelled/deleted without a booking transition having
  recorded it — which §5.5 makes impossible, so this branch is
  defensive).

### 5.4 `POST /book/m/{token}/confirm`

One transaction: read `events.start_utc`; `UPDATE bookings SET
state='confirmed', confirmed_at WHERE id=? AND state='held' AND
hold_expires_at > ? AND ? < start_utc`; `RowsAffected == 1` or stop and
re-render §5.3. Then `UPDATE events SET status='confirmed', sequence =
sequence + 1`; attendee row; queue `(confirmed, host, seq)`. After
commit: the host's default reminder (its own function; the booking is
correct without it). Then try the row once. A second press affects
zero rows and does nothing.

### 5.5 The manage page, the allowlist, and the host's authority

Manage shows: the snapshotted title and description, when (booker
zone; host zone if different), who (host name/handle and address), and
**Add to calendar** (`…/calendar.ics`), **Reschedule**, **Cancel**.

**The public allowlist.** Every public page, booker mail and the ICS
may contain ONLY: the booking's snapshotted title and description, the
event's `start`/`end`/`status`/`sequence`/`deleted_at`, the host's
name/handle and address, the booker's own name and address,
`cancelled_by`, the booking's state. **Never** the event's title,
notes, location or attendee list. Public templates receive a
`publicBooking` struct with exactly those fields; they never receive an
event or a booking. A test edits notes, location and attendees as the
host after confirmation and asserts none reaches manage, ICS or mail.

**The host's authority (B18).** The host edits the event through the
ordinary form, and `UpdateEvent`/`DeleteEvent` enforce, inside their
transactions, for any event a booking references:

- **Scheduling fields are read-only to the host.** `UpdateEvent`
  compares the submitted `start_local`, `end_local`, `tz`, `all_day`,
  `kind` and `rrule` with the stored row and refuses if ANY differs:
  `ValidationError{"start", "This is a booked meeting. Its time is
  changed by the person who booked it, or by cancelling and letting
  them rebook."}` (one message; the field named is whichever differed
  first). When the event is booked, the form renders those controls
  DISABLED with that sentence beside them AND carries the stored
  values as hidden inputs — `start`, `end`, `tz` (the EVENT's own zone,
  not the host's profile zone), `all_day`, `kind`, `repeat` — so a
  scriptless title-only save submits exactly what is stored and
  passes; `readEventForm` takes `tz` from that hidden field when the
  event is booked and from `p.Zone` otherwise (B25). A stale form that
  still submits the old values passes because they match — and one
  that submits values from before a public reschedule fails because
  they do not (r4 A5). `kind` stays `meeting` forever (r4 B1).
- `status` depends on the booking's state (r6 f3). Booking `held`:
  the event is `tentative`; the host may leave it `tentative` or set
  `cancelled` (→ booking `expired`); `confirmed` is refused ("Only the
  person who booked this can confirm it.") — promotion is the confirm
  transaction's alone, and the lapsed-hold exclusion in §4 depends on
  a held event staying tentative. Booking `confirmed`: `confirmed` or
  `cancelled`. Booking `cancelled` or `expired`: `confirmed` is refused
  ("This booking was cancelled. Make a new event instead.");
  `tentative` is never accepted from the form for a booked event. Any
  update to a soft-deleted event is refused.
- `status = cancelled` on a `confirmed` booking → booking `cancelled`,
  `cancelled_by = host`, reminder queue rows deleted, unsent mail
  deleted, `(cancelled, booker, seq)` queued. On a `held` booking →
  `expired`, unsent mail deleted, nothing queued.
- `DeleteEvent` → the same, plus `sequence = sequence + 1` on the
  soft-deleted row; a `held` booking → `expired`.
- `sequence` bumps are `gorm.Expr("sequence + 1")` everywhere.
- The reminder claim path drops a queue row whose event is `cancelled`.

No revision field on the general event form is needed: the only
fields a stale form could move are refused by the comparison above,
and the rest (title, notes, location) the booker never sees.

### 5.6 Reschedule and cancel

`GET …/reschedule`: the §5.1 picker with the booking's event excluded
from busy, computed with the EVENT's duration and the HOST's current
zone, and a hidden `rev` = `events.sequence`. `POST …/reschedule` with
`start`, `zone`, `rev`. Inside `holdMu`: state `confirmed`;
`reschedules < 5` (else 429); `FreeSlots` check for the response; one transaction that first takes
the write lock with the same no-op write, re-runs the strict busy read
and slot check through the transaction (B12), then `UPDATE events SET
start_local, end_local, tz, start_utc, end_utc, sequence = sequence + 1
WHERE id = ? AND sequence = ?` (rev) — **all
five time columns together, `tz` = the zone the slot was computed in**
(B21) — zero rows → re-render; `reschedules = reschedules + 1`; unsent
`rescheduled` rows deleted; `(rescheduled, booker + host, seq+1)`
queued; **and for every `Reminder` RULE on the event — not every queue
row, because a fired reminder has none — a queue row is upserted from
the new start in the same transaction** (a `tx` form of
`rescheduleAfter` keyed by `reminder_id`, which the unique index
already enforces one-per-rule; it recomputes `due_at`, clears
`claim_id`, `claimed_at`, `retry_at`, and resets `fired_at`) (B24,
B26). Try both mails after commit.

`POST …/cancel`: `UPDATE bookings SET state='cancelled', cancelled_at,
cancelled_by='booker' WHERE id=? AND state='confirmed'`; event
`status='cancelled', sequence = sequence + 1`; reminder queue rows
deleted; ALL unsent mail rows deleted; `(cancelled, both, seq)` queued.
A second press affects zero rows.

### 5.7 Transitions

| From | Trigger | To | Guard (in the UPDATE) | Same transaction |
|---|---|---|---|---|
| — | hold | `held` | caps; slot free; `holdMu` | tentative event; snapshot; `mail(hold, booker, 0)` |
| `held` | confirm POST | `confirmed` | `hold_expires_at > now AND now < events.start_utc` | event confirmed, seq+1; attendee; `mail(confirmed, host, seq)` |
| `held` | sweep | `expired` | `hold_expires_at <= now` | event soft-deleted; reminder queue rows deleted; unsent mail deleted |
| `held` | host cancels/deletes event | `expired` | — | unsent mail deleted |
| `confirmed` | reschedule | `confirmed` | slot free; `reschedules < 5`; `holdMu`; `events.sequence = rev` | five time columns + seq+1; `reschedules+1`; unsent `rescheduled` deleted; `mail(rescheduled, both, seq)` |
| `confirmed` | booker cancels | `cancelled` | `state='confirmed'` | event cancelled, seq+1; queue rows deleted; all unsent mail deleted; `cancelled_by=booker`; `mail(cancelled, both, seq)` |
| `confirmed` | host cancels/deletes event | `cancelled` | `state='confirmed'` | seq+1; queue rows deleted; all unsent mail deleted; `cancelled_by=host`; `mail(cancelled, booker, seq)` |
| any | sweep at `ip_forget_at` | same | `ip_fp IS NOT NULL` | `ip_fp = NULL` |

Every transition is a conditional `UPDATE` checking `RowsAffected ==
1`; zero rows → re-read and render, never retry. No mail is sent inside
a transaction; rows are.

### 5.8 The ICS (`WriteBookingICS`)

A dedicated writer in `booking_ics.go` taking the `publicBooking`
struct and the event's opened UID. `WriteICS` is untouched and still
says `METHOD:PUBLISH` for calendar exports; it is never called here.

```
BEGIN:VCALENDAR / VERSION:2.0 / PRODID / CALSCALE:GREGORIAN
METHOD:REQUEST            -- state confirmed, event not cancelled/deleted
METHOD:CANCEL             -- otherwise
BEGIN:VEVENT
UID:{event UID}           -- stable across reschedules
DTSTAMP:{event updated_at}
SEQUENCE:{event sequence}
DTSTART;TZID={event tz}:{start_local}   DTEND;TZID=…:{end_local}
SUMMARY:{snapshot title}   DESCRIPTION:{snapshot description, if any}
ORGANIZER:mailto:{host address}
ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:{booker}
STATUS:CONFIRMED | CANCELLED   -- effective state, not events.status alone
END:VEVENT / END:VCALENDAR
```

Served at `GET /book/m/{token}/calendar.ics` for `confirmed` and
`cancelled` bookings (`text/calendar; charset=utf-8`,
`Content-Disposition: attachment; filename="booking.ics"`); `held` and
`expired` → 404. The event row is read including soft-deleted ones, so
a host-deleted booking still exports its CANCEL. Folding, TEXT escaping and
date formatting reuse `ics_export.go`'s helpers. **No `CN=`
parameters**: a parameter value has its own quoting rules (RFC 5545
§3.2, RFC 6868) that `escapeText` does not implement, and a name is
not needed for the invitation to work — `SUMMARY` names the meeting
and the addresses name the people (r4 B6). No `URL:`, no `LOCATION`,
no other attendee.

---

## 6. Mail

Plain text through `mail.Sender`; one configured sender; the host's
address is in the BODY and as `ORGANIZER`. Bodies are `text/template`
files in `templates/mail/`, rendered at send time from allowlisted
fields and the CURRENT state (B19). No booker content in any log line.

| kind | to | Says |
|---|---|---|
| `hold` | booker | "Somebody — we hope you — asked to book {title} with {host} on {when}. If that was you, confirm it here: {link}. If not, do nothing; it expires at {expiry}." |
| `confirmed` | host | "{name} ({address}) booked {title}, {when}. Note: {note}. It is on your calendar: {event link}." If the booking is no longer confirmed when this renders, the body says so in its first line. |
| `rescheduled` | booker, host | The current time in the recipient's zone; the manage link (booker) or event link (host). "If you added this to a calendar, download it again — the file does not update itself." |
| `cancelled` | booker, host | What, when it had been, `cancelled_by` in words. |

**Sending.** All mail — reminders included — goes through ONE wrapper
around the configured sender, `boundedSender`:

- At most **4** sends in flight process-wide (a semaphore). A fifth
  caller gets `ErrMailBusy` immediately and its row is retried later.
- Each attempt runs `Send` in a goroutine and waits at most
  `min(20s, remaining budget)`, where the budget is the caller's
  context deadline. Past that it returns `ErrMailTimeout`; the
  goroutine is abandoned and releases its semaphore slot when the
  underlying send eventually returns or the OS closes the connection.
- **Honest limit (r3 f3):** rastrillo's `mail.SMTP` ignores its
  context and sets no dial or protocol deadline, so an abandoned send
  can hold a goroutine and a socket until the kernel gives up. The
  semaphore bounds how many. The fix belongs upstream — a `mail.SMTP`
  that honours `ctx` — and is recorded in the calendar spec's debt list
  in the same commit. This spec does not claim to bound what it cannot.

Success → acknowledge with the claim id. `ErrMailBusy` → DEFER with
the claim id: due again in 30 seconds, attempts untouched, because no
SMTP call was made and a full wrapper is not a failed address (r4 B4).
Any other error → fail with the claim id (attempts + 1; `due_at` is
already the lease end). Nothing is concluded from an error except "try
again later" (B16).

**Reminders get the same three outcomes and the same identity** (B22).
`reminder_queues` gains two nullable columns by an additive migration:
`claim_id` (random, set on claim, cleared by any reschedule of the
row) and `retry_at`. `due_at` keeps its meaning — occurrence start
minus offset — and is never written by a retry (r5 f3). Claim
eligibility is `fired_at IS NULL AND due_at <= now AND (retry_at IS
NULL OR retry_at <= now) AND (claimed_at IS NULL OR claimed_at <= now -
claimLease)` (`<=` at both boundaries — r5 f5). **The claim is one
transaction: the conditional UPDATE, then a SELECT of the same row by
id, and the reminder's payload — the occurrence start it announces,
the offset, the event — is built from THAT row, never from the
candidate row the worker read before claiming** (r6 f2): a reschedule
between the candidate read and the claim rewrites the row, the claim
still succeeds, and a payload built from the stale candidate would
announce the old time with a valid claim in hand. The same one
transaction rule applies to `ClaimBookingMail`. `MarkReminderSent`,
`FailReminder` (attempt + 1, `claimed_at` refreshed), the give-up path,
the `ErrMailBusy` deferral (`retry_at = now + 30s`, `claimed_at =
NULL`, attempts untouched) and `RescheduleAfterFiring` are each
`WHERE id = ? AND claim_id = ?` and each checks `RowsAffected`; the
mark and the advance happen in ONE transaction so a worker that loses
its claim between them does neither. Zero rows → the worker stops; its
message was sent and described the old time (§10).

---

## 7. Abuse

### 7.1 Durable caps, from `bookings` rows, inside `holdMu`, before slot arithmetic

| Cap | Key | Window | Limit |
|---|---|---|---|
| Open holds | `ip_fp` | `state='held' AND hold_expires_at > now` | 5 |
| Open holds | `page_id` | same | 20 |
| Open holds | `booker_address_fp` | same | 2 |
| Attempts | `ip_fp` | `created_at > now - 24h` | 20 |
| Attempts | `booker_address_fp` | `created_at > now - 24h` | 6 |
| Reschedules | booking | lifetime | 5 |

Over → 429 "Too many bookings in progress. Try again later."

### 7.2 The gate (r2 B7, r3 f8)

An in-process token bucket per `ip_fp` on every public route: 60
requests/minute, 429 beyond. **Bounded:** at most 10,000 keys; a key
idle for five minutes is evicted on the next insert; when full with no
idle key, a NEW key is refused with 429 and `Retry-After: 60` (r4
B7). Memory only, resets on restart. The gate also
sets the two public headers. It is the cheap wall in front of the
semaphore (§4.2) and the durable caps (§7.1).

### 7.3 What this does not stop

A patient adversary with many mailboxes and addresses books real slots
one at a time. The host cancels them and, if it continues, unpublishes.

### 7.4 The oracle, stated as new

A published page lets anyone ask when this host is free, and every
granted calendar shapes the answer. Publishing is deliberate: the
button reads **Publish** and the sentence above it reads "Anyone with
this link can see when you are free — not what you are doing, but when
— including time blocked by team calendars you can see." AGENTS.md's
leak list gains this as item 5.

---

## 8. The sweep

`drain` (the tick) runs, in order, inside the 45-second `drainDeadline`
shared by everything, **cheap work first, delivery last** (r3 f3):

1. **Expire holds**: `held AND hold_expires_at <= now` → `expired`,
   event soft-deleted, queue rows deleted, unsent mail deleted. ≤ 200.
2. **Forget IPs**: `ip_fp IS NOT NULL AND ip_forget_at <= now` → NULL.
   ≤ 500.
3. **Reminders**, as today but through `boundedSender`, until
   `deadline/2` or the queue is empty.
4. **Booking mail**: claim rows `sent_at IS NULL AND due_at <= now`
   (lease-aware), oldest first, until the deadline, each attempt
   bounded by the remaining budget.

`DrainOnRequest` (a signed-in page view) does ONE thing: an expiry if
any is due, else a due reminder, else one booking mail. Never more,
and never with more than a 5-second budget.

`NextDueAt` = the earliest CLAIMABLE instant of: reminders —
`max(due_at, coalesce(retry_at, 0), coalesce(claimed_at + claimLease,
0))` per unfired row (the existing mismatch, fixed here — r4 A6); next `held` `hold_expires_at`;
next `booking_mails.due_at` with `sent_at IS NULL` (claims advance it,
so it is always claimable); next `ip_forget_at` with `ip_fp IS NOT
NULL`.

---

## 9. Testing

- **Parser**: every §3.1 example; inheritance; first clause without a
  day; midnight; `25-26`, `9:60`, `13pm`, `24:00` start vs end,
  `12:30-2:30`; each separator; the en dash; three ranges; two problems
  both reported; merged and touching windows.
- **Slots**: 12–3 / 60 with and without buffer; busy ending 5 min before
  `from` still removes the first slot when buffered; tentative blocks;
  lapsed unswept hold does not; cancelled does not; a note does not; a
  recurring all-day commitment blocks the OCCURRENCE date; an empty-zone
  all-day event blocks the host's whole day in `Asia/Tokyo`; notice
  floor 10 min; horizon bounds `end`; `to` bounds start; spring-forward
  and fold Sundays with 00:00–04:00: no offered slot straddles the
  transition, both ends round-trip, every slot is exactly its duration
  in elapsed seconds, pinned unix numbers; `DurationMin: 0` and an
  inverted window return errors, not lists.
- **Range query**: the moved-override-of-finished-series case; the
  long-final-occurrence case; a daily 01:30 Dublin series created 28
  March shows 01:30 on 30 March and on a September day (gap drift); a
  fortnightly MO,WE series from Wednesday 2 September shows 14 AND 16
  September; `FREQ=MONTHLY;BYDAY=MO` is foreign, shows once, and fails
  the strict reader; the final occurrence across the fold
  with `UNTIL` the night before (the 25-hour slack); the 47-hour
  two-day weekly event keeps its second day on later occurrences; a
  1700 daily rule → `ErrIncompleteExpansion` from the strict reader
  and a 503 from the public page, while `/month` still renders; an
  imported busy series with a `BYSETPOS` rule → the same, and the rule
  is still stored verbatim.
- **Concurrency**: 20 goroutines, one slot, one winner, `-race`; a
  host `CreateEvent` committed between a hold's response-time slot
  check and its transaction (inject via a test hook that runs a
  callback after the check) → the hold fails with `ErrSlotTaken`, no
  tentative event exists; the same for reschedule.
- **Flow**: hold → one mail row, one send; GET link changes nothing;
  confirm POST confirms, attendee, default reminder, one host mail;
  second POST nothing; confirm after `start` refused; 409 on a taken
  slot; wrong token 404 byte-identical; expired says expired; ICS
  `METHOD:REQUEST`, `ORGANIZER`, `ATTENDEE …ACCEPTED`, `SEQUENCE` =
  event's, no `URL:`, parses with `ParseICS`; ICS for `held` → 404;
  reschedule with the right `rev` bumps sequence, writes `tz`, mails
  both; the SAME form resubmitted affects nothing; cancel →
  `METHOD:CANCEL`, unsent rows gone; host adds note/location/attendee →
  none reaches manage, ICS or mail; host cancels via edit → booker
  mailed, page says cancelled by host; host deletes → same, CANCEL ICS
  with higher sequence; host edits refused: any change to start, end, tz, all_day,
  kind or rrule (each tested), reviving a cancelled booking, editing a
  deleted event; a scriptless host edit that changes only the title succeeds,
  and still succeeds after the host changes their profile zone (the
  hidden `tz` is the event's); a stale host form submitted after a
  public reschedule is refused; the event form shows the scheduling
  controls disabled AND carries hidden inputs for each, for a booked
  event; page title changed after hold →
  manage and ICS still show the snapshot.
- **Zone change**: host moves Dublin → Tokyo; reschedule writes
  `tz=Asia/Tokyo` and all four time columns agree (`ToUTC(start_local,
  tz) == start_utc`).
- **Mail queue**: token recoverable after a simulated restart (insert
  row, `tick`, link works); a refused host mail is retried at `due_at`
  and gives up at `MaxReminderAttempts`; a stale `claim_id` cannot
  acknowledge or fail a row another claim holds; two drains do not
  send one row twice; cancel deletes every unsent row; `NextDueAt`
  reflects `due_at` not the lease; `boundedSender` returns
  `ErrMailTimeout` within budget against a stalling sender and
  `ErrMailBusy` when four are in flight; a row deferred by
  `ErrMailBusy` keeps `attempts = 0` and sends once capacity returns;
  a crash after a claim (simulated: claim, do not ack) leaves
  `NextDueAt` at the lease end, not now.
- **Caps, gate, semaphore**: sixth open hold per IP → 429; 21st attempt
  in 24h → 429 with none open; `MaxReschedules`; 61st public GET in a
  minute → 429; the gate holds ≤ 10,000 keys after 20,000 distinct IPs;
  `?day=` past the horizon → 404; five concurrent public slot
  computations → one 503 with `Retry-After`.
- **Sweep**: `tick` expires, soft-deletes, frees; retries owed mail;
  nulls `ip_fp` at `ip_forget_at`; reminders still progress with 30
  mail rows queued behind a slow sender; `DrainOnRequest` does one
  thing; `NextDueAt` returns each source when earliest.
- **`Zone()`**: 100 goroutines × garbage under `-race`; no failures
  cached.
- **Reminders**: a cancelled event's queue row is dropped by the claim
  path; a worker that claimed a row which was then rescheduled AND
  re-claimed by another worker in the same second cannot mark, fail,
  defer or advance it (`claim_id`, not `claimed_at`); a reschedule
  followed by a simulated crash (no post-commit code) leaves the queue
  row already pointing at the new time; a reminder that FIRED (queue row
  deleted) before the booker moved the meeting to tomorrow gets a new
  queue row for tomorrow; a reschedule that lands between
  a worker's candidate read and its claim makes the worker announce the
  NEW time (the payload comes from the claimed row); a title-only host
  save of a HELD event succeeds and leaves it tentative; a host attempt
  to set a held event `confirmed` is refused; an `ErrMailBusy` deferral
  leaves `due_at` untouched and the reconstructed meeting time correct;
  a claim exactly `claimLease` old is claimable; `NextDueAt` for a
  claimed reminder is the lease end.
- **ICS**: a booker named `Doe, Jane "JJ": Support` produces a file
  the app's parser AND a line-by-line check accept, with the address
  unchanged and no `CN=`.
- **The one rule**: booker name, address, note, the token, the
  snapshotted title and description, the page's availability — none in
  the raw bytes.
- **Logs**: no address, no token, after a failed send.
- **Browser**: JS off — book, "check your mail", open the link, press
  Confirm, see Reschedule. JS on — the settings pane fills in.

---

## 10. What is NOT in this version

- Group / round-robin pages; booker custom fields; payments; video
  links; reminders to the booker.
- **Host-initiated reschedule.** A booked event's time belongs to the
  booking flow (B18); the host's form cannot move it, and says so
  beside the disabled controls. To move a booked meeting the host
  cancels and lets the booker rebook. Cancel and delete DO tell the
  booker.
- **Automatic calendar sync.** The ICS is a download; the change mail
  says to download it again.
- **A revision field on the general event form.** Booked events are
  protected by the field lock instead (§5.5).
- **A context-honouring SMTP sender.** Upstream (§6).
- **Recalling a reminder mid-send.** A reminder that was already
  handed to the sender when the booker rescheduled describes the old
  time; the reschedule mail that follows describes the new one.
- Locale; retention (rows stay, sealed, minus `ip_fp`).

---

## 11. Changes to AGENTS.md and the calendar spec in the same commit

- Settled decisions: rows B1, B2, B4, B5, B8, B11–B21.
- Frontend: **"Except a BOOKER, who typed their address to be mailed.
  Booking mail is the one outbound to a non-team address."**
- Data: **"Booker name, address, note and TOKEN are sealed; the address
  has the keyed fingerprint. A page's public code is generated, never a
  chosen slug. Windows are plaintext: shape-of-time."**
- Data: **"Every booking transition is a conditional UPDATE naming the
  state and revision it expects, checking RowsAffected. Zero rows is
  'someone moved it first', never a retry."**
- Data: **"A booked event's start, end, tz, all_day, kind and rrule
  belong to the booking flow. A host edit that changes any of them is
  refused; the form shows them disabled."**
- Recurrence: **"`Expand` may stop early. Anything that decides FREE
  time reads `EventsInRangeStrict` and fails closed on
  `ErrIncompleteExpansion`."**
- Recurrence: **"A day span is a difference of CIVIL dates, never
  elapsed hours over 24."**
- Reminders → **Ticks**: **"`NextDue` is the earliest CLAIMABLE
  instant across reminders, hold expiries, owed booking mail and
  IP-forgets. New scheduled work goes here, never in a goroutine.
  Cheap work runs before delivery; reminders get the first half of the
  delivery budget."**
- Reminders: **"No sender error is definite. Never free a resource
  because a send 'failed'. Every send goes through `boundedSender`.
  `ErrMailBusy` is a deferral, not an attempt."**
- Reminders: **"Every queue row has a random `claim_id`; every
  completion mutation is conditional on it and checks RowsAffected,
  and a payload is built from the row the claim returned, never from
  the candidate read. A reminder's `due_at` is the occurrence minus the
  offset and is never written by a retry — `retry_at` is. Booking
  mail's `due_at` is retry eligibility only and has no `retry_at`."**
- Reminders: **"A reschedule repairs the event's queue rows in the
  same transaction as the move."**
- Time: **"`Zone()` takes bounded input on public routes: `CommonZones`
  plus the host's own zone."**
- Time: **"A booking slot is the same length on the wall clock and the
  elapsed clock, or it is not offered."**
- The one rule's exclusions, item 5: the published-page oracle.
- `2026-09-02-calendar-design.md` §7 debt list: **"rastrillo `mail.SMTP`
  ignores its context; an abandoned send holds a goroutine and a
  socket until the kernel closes it. Bounded to four in flight here;
  fixed properly upstream."**

---

## 12. Review

Reviewer: **GPT-6** via `codex-cli 0.153.0` (identified itself as
`gpt-6-astra` in round 2; rounds 1 and 3 could not expose the runtime
identifier). 2026-09-07.

**Round 1: REWRITE, 21 findings, all accepted.** **Round 2: REWRITE,
7 new + 11 reopened, all accepted.** Both tables are in the git history
of this file (`9a28d7f`, `0999c0d`, `13b43a4`).

**Round 3: REWRITE.** 11 new findings; of round 2, A11, A21, B2, B7
PARTIAL and B4 DOES NOT HOLD. Dispositions:

| Ref | Finding | Disposition |
|---|---|---|
| 1 | Zero-length host edit loops slot generation inside the mutex | **Accepted — B18.** Invariants on booked events; `ComputeSlots` refuses bad input (§4.0, §5.5). |
| 2 | Reschedule after a host zone change writes a contradictory record | **Accepted — B21.** `tz` written with the four time columns (§5.6). |
| 3 | The timed sender bounds waiting, not sending; reminders unbounded; 20 s ignores the shared deadline | **Accepted as far as this repo can, scoped where it cannot.** `boundedSender` wraps ALL mail, caps in-flight at 4, waits `min(20s, remaining budget)`; cheap sweep work runs before delivery. The socket-level bound needs rastrillo's `mail.SMTP` to honour its context; recorded as upstream debt (§6, §11). |
| 4 | Queue revisions do not identify content; a late "confirmed" after a cancel | **Accepted — B19.** Mail says the current state; cancel/expire delete every unsent row; a `confirmed` body opens by saying the booking is no longer confirmed if so; already-sent rows cannot be recalled and the spec says it. |
| 5 | Ordinary host edits contradict terminal booking states | **Accepted — B18.** Reviving refused; deleted events refused; no general-form revision, stated in §10. |
| 6 | `NextDueAt` advertises leased work; claims have no identity | **Accepted — B13.** `due_at` carries the retry time; `claim_id` on claim, ack and fail (§2.5, §8). |
| 7 | `UNTIL` exclusion misses a long final occurrence | **Accepted, a second calendar bug.** `until_utc + duration < from` (§4.1). |
| 8 | Unbounded bucket; unbounded work per request | **Accepted.** Bucket capped at 10,000 with idle eviction; a 4-wide semaphore on public slot computation, 503 when full (§4.2, §7.2). Expansion cost per request is the calendar's own and is not re-bounded here. |
| 9 | ICS contract missing; held/expired undefined; effective status | **Accepted.** §5.8. |
| 10 | Live page text changes the ICS without a new `SEQUENCE` | **Accepted — B20.** Title and description snapshotted onto the booking. |
| 11 | Token generation unspecified | **Accepted — B14.** 32 bytes `crypto/rand`, hex, collision handling. |

Rejected: none. Scoped rather than solved: the socket-level send
bound (finding 3, upstream) and the per-request cost of recurrence
expansion (finding 8, the calendar's own).

**Round 4: REWRITE.** 7 new (6 blockers, 1 hardening); of round 3,
A5, A6, A7 PARTIAL; 3 and 8 HOLD as scoped. Dispositions:

| Ref | Finding | Disposition |
|---|---|---|
| A5 | A stale host form re-saves old scheduling fields past every invariant | **Accepted — B18 rewritten.** Scheduling fields are read-only to the host; `UpdateEvent` refuses any difference (§5.5). |
| A6 | A claim does not advance `due_at`; reminders have the same mismatch | **Accepted — B22.** Claim sets `due_at = lease end`; reminders' `NextDueAt` uses the claimable instant (§2.5, §8). |
| A7 | Master duration is not a conservative bound across DST | **Accepted.** 25-hour slack on the `UNTIL` exclusion (§4.1). |
| B1 | Kind → `note` makes a confirmed booking non-busy | **Accepted — B18.** `kind` is a locked field. |
| B2 | `daysBetween` loses a day across spring DST | **Accepted, a third calendar bug.** Civil-date difference (§4.1). |
| B3 | A reminder finishing mid-reschedule deletes the replacement | **Accepted — B22.** Mark and advance conditional on the claim (§6). |
| B4 | `ErrMailBusy` burns attempts without reaching SMTP | **Accepted — B22.** Defer path, both queues (§2.5, §6). |
| B5 | `Expand` truncation read as free time | **Accepted — B23.** `ExpandComplete`, `EventsInRangeStrict`, 503 fail-closed (§4.1). |
| B6 | `CN=` cannot reuse TEXT escaping | **Accepted by omission.** No `CN=` (§5.8). |
| B7 | Full bucket with no idle key undefined | **Accepted.** New key refused, 429 + `Retry-After` (§7.2). |

Rejected: none.

**Round 5: REWRITE.** 4 blockers, 2 hardening; of round 4, A6, B3,
B4 PARTIAL. Dispositions:

| Ref | Finding | Disposition |
|---|---|---|
| A6/B3/f1 | `claimed_at` (whole seconds) is not a claim identity; failure and give-up paths unconditioned | **Accepted — B22 rewritten.** `claim_id` on `reminder_queues`; every mutation conditional; mark + advance atomic (§6). |
| f2 | Reschedule commits before the reminder repair | **Accepted — B24.** Queue rows rebuilt inside the reschedule transaction (§5.6). |
| B4/f3 | Writing a retry time into a reminder's `due_at` moves the meeting | **Accepted — B22.** `retry_at` column; `due_at` never written by a retry; `NextDueAt` over all three (§6, §8). |
| f4 | Disabled controls submit nothing; reader's `TZ = p.Zone` | **Accepted — B25.** Hidden snapshot incl. the event's `tz`; reader takes it for booked events (§5.5). |
| f5 | `<` vs `<=` at the lease boundary | **Accepted.** `<=` both sides (§6). |
| f6 | §10 still described host rescheduling | **Accepted.** Rewritten (§10). |

Rejected: none.

**Round 6: REWRITE.** 3 blockers, 1 wording; round 5's six all HOLD.

| Ref | Finding | Disposition |
|---|---|---|
| f1 | An unparseable rule is not "truncated", so the strict reader let it through | **Accepted — B23 widened.** Strict fails on unparseable busy rules too (§4.1). |
| f2 | A valid claim can carry a stale payload from the candidate read | **Accepted — B22.** Claim = conditional UPDATE + re-read in one transaction; payload from the re-read (§6). |
| f3 | A held event has no permitted unchanged status | **Accepted.** Status rules keyed on booking state; host cannot promote a hold (§5.5). |
| f4 | B22/§11 said `retry_at` for both queues | **Accepted.** Wording aligned (B22, §11). |

Rejected: none.

**Round 7: REWRITE.** 3 blockers, 0 polish; round 6's four HOLD.

| Ref | Finding | Disposition |
|---|---|---|
| f1 | Spring-gap resolution drifts every later occurrence | **Accepted, a fourth calendar bug.** Advancement carries the original civil clock (§4.1). |
| f2 | Final busy check outside the write transaction; host `CreateEvent` slips in | **Accepted — B12 widened.** No-op write for the RESERVED lock, then the strict read, then the insert, one transaction (§5.2, §5.6). |
| f3 | Rebuilding queue rows misses a fired reminder | **Accepted — B26.** Enumerate `Reminder` rules; upsert (§5.6). |

Rejected: none.

**Round 8: REWRITE.** 2 blockers, both recurrence-engine bugs;
round 7's three HOLD.

| Ref | Finding | Disposition |
|---|---|---|
| f1 | Fortnightly `BYDAY` skips the first selected weekday of an eligible week | **Accepted, a fifth calendar bug.** Enumerate selected weekdays per eligible week (§4.1). |
| f2 | `ParseRule` accepts combinations the expander misreads | **Accepted, a sixth.** Combination validation → `ErrForeignRule`; strict fails closed (§4.1). |

Rejected: none.

**Spec review closed at revision 9.** Eight rounds, 58 findings, 58
accepted (two scoped). The design has been stable since revision 5;
rounds 6–8 moved transaction boundaries and found six bugs in the
recurrence engine that every calendar view already had. Further review
is of the branch's code, recorded in the calendar spec's review log.
