oficina / calendar Public

booking: spec revision 3 after the second review

gpt-6-astra returned REWRITE again: 7 new findings and 11 of the 21
earlier ones partial or not holding. The new ones were structural. The
mail outbox could not rebuild a link it held only a hash of, so the
token is now sealed as well as hashed. One mutable mail marker lost a
cancellation that landed mid-send and let three drains send one mail,
so mail is a queue table with a lease — the reminder queue's own shape.
A five-minute slot held at 09:59 could be confirmed at 10:20, so a hold
expires at the meeting's start and confirmation requires now < start.
smtp.SendMail can fail after acceptance, so no sender error is
definite and nothing is freed on one. A 00:30 slot on the fold Sunday
stored as two hours, so both ends round-trip and a slot that straddles
a transition is not offered. And the range query drops a moved
occurrence of a finished series, in every view, which is a calendar
bug this spec now owns.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 13b43a4689bc3bfe02dde34aeeee9489c1b52b4f parent c48c1e2
1 file changed, +473 −455
  • docs/superpowers/specs/2026-09-07-booking-design.md +473 −455
diff --git a/docs/superpowers/specs/2026-09-07-booking-design.md b/docs/superpowers/specs/2026-09-07-booking-design.md
index 6d093f7..90456ce 100644
--- a/docs/superpowers/specs/2026-09-07-booking-design.md
+++ b/docs/superpowers/specs/2026-09-07-booking-design.md
@@ -4,8 +4,8 @@
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
-2** after a review by GPT-6 through `codex-cli 0.153.0` returned
-REWRITE with 21 findings; §12 records each and what changed.
+3.** Reviewed twice by `gpt-6-astra` through `codex-cli 0.153.0`; both
+rounds returned REWRITE. §12 records every finding 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
@@ -23,14 +23,15 @@ AGENTS.md, AGENTS.md is wrong and is fixed in the same commit (§11).
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, not chosen. 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. Unclaimed holds expire in
-thirty minutes and are swept on the existing tick.
+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.
---
@@ -38,35 +39,35 @@ thirty minutes and are swept on the existing tick.
| # | 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 itself | Any typed address would fill a calendar. And a GET that confirms is confirmed by every link-scanning mail gateway on earth, so a stranger typing YOUR address would book you a meeting you never saw (review finding 2). The mailed link is a request; the button is the consent. |
+| 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 | Any typed address would fill a calendar. A GET that confirms is confirmed by every link-scanning mail gateway, so a stranger typing YOUR address books you a meeting you never saw (r1 f2). The mailed link is a request; the button is the consent. |
| 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` plus a GENERATED page code** | A chosen slug per page | A slug is prose: `redundancy-consultation` in a plaintext column, joined to events through `bookings.page_id`, says what those events ARE. The one rule forbids that. The handle is "who is on the team", which AGENTS.md already gives up. (Finding 1.) |
-| 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 download at a tokenised URL, not an attachment, and carries NO management link** | An attachment; `URL:` pointing at the manage page | `mail.Sender` is plain text. And a management capability inside an ICS is exported to wherever that calendar syncs — a shared team view, a phone, a second export. Delegating write authority is not the same as disclosing contents. (Finding 6.) |
-| 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. 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. |
-| B10 | **Unknown token → 404**, never 403, never "expired" | Distinguishing expired from wrong | Status codes cannot enumerate bookings. An expired hold's page says "expired" only when the token was RIGHT. |
-| B11 | **The EVENT's `sequence` is the one revision counter**; the booking has none | A booking-side sequence | Two counters for one meeting drift the first time the host edits it (finding 11). `UpdateEvent` already bumps `sequence`; every booking transition goes through the same column. |
-| B12 | **Hold creation is serialised by a process-wide mutex**, and the availability check runs inside it | Relying on the write transaction | SQLite begins deferred; two transactions both read "free" before either takes the write lock (finding 9). One binary, one process, one instance: a mutex is the honest serialisation, and a concurrent test proves it. |
-| B13 | **Mail is sent AFTER the row commits, and an unsent notification is a column the drain retries** | Rolling back rows when a send fails | SMTP can accept and then time out; deleting the row leaves a delivered link that 404s (finding 12). A committed row plus a `pending_mail` marker is at-least-once, like reminders. |
+| B3 | **Pages are per person, any number** | One page per person | "15 min chat" and "60 min call" are different pages. |
+| B4 | **The public identifier is a chosen plaintext `handle` plus a GENERATED page code** | A chosen slug | A slug is prose joined to events through `bookings.page_id` (r1 f1). The handle is "who is on the team", already given up. |
+| 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 | Quick-add attendees stay recorded-not-invited. The booker asked for the mail. |
+| B6 | **The ICS is a download at a tokenised URL, not an attachment, and carries NO management link** | An attachment; `URL:` | `mail.Sender` is plain text. A management capability inside an ICS syncs to wherever that calendar goes (r1 f6). |
+| B7 | **The availability text has ONE parser and it is Go** | A JS parser | The standing rule. |
+| B8 | **A held slot is a tentative EVENT** | A holds table | The range query already subtracts events; the host sees the hold. |
+| 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 hours. |
+| B10 | **Unknown token → 404**, never 403, never "expired" | Distinguishing | Status codes cannot enumerate bookings. |
+| B11 | **The EVENT's `sequence` is the one revision counter**, bumped atomically (`sequence = sequence + 1`), and every booking-side conditional update names the revision it expects | A booking sequence; stale-read `existing.Sequence + 1` | Two counters drift (r1 f11); a stale read loses a concurrent bump (r2 A11); a resubmitted reschedule form must fail, not apply twice (r2 A10). |
+| B12 | **Hold creation and reschedule serialise on a process-wide mutex**, with the availability check inside it | The write transaction alone | SQLite begins deferred (r1 f9). One process per instance. |
+| B13 | **Mail is a QUEUE TABLE, one row per (booking, revision, recipient), claimed with a lease and acknowledged conditionally — the reminder queue's own pattern** | A `pending_mail` column on the booking | One mutable marker loses a cancellation that lands mid-send, lets three drains send the same mail, and cannot record that one of two recipients failed (r2 B2). |
+| B14 | **The token is stored SEALED as well as hashed** | Hash only, as feeds do | A queue that must resend a link needs the link. Sealed at rest satisfies the one rule; the feed's hash-only choice was about never re-showing a URL, which this feature cannot afford (r2 B1). |
+| B15 | **A hold expires at the EARLIER of thirty minutes and the meeting's start**, and confirmation requires `now < start` | Thirty minutes flat | A five-minute meeting held at 09:59 could be confirmed at 10:20, after it ended (r2 B5). |
+| B16 | **No sender error is "definite".** A failed hold mail leaves the hold in place and the queue retries; there is no `failed` state | Marking the hold failed and freeing the slot | `smtp.SendMail` can return an error AFTER the message was accepted — on `QUIT`, say — so "failed" would leave a delivered link pointing at a released slot (r2 A12). |
+| B17 | **A slot must be the same length on the wall clock and on the elapsed clock**; a candidate that straddles a DST transition is not offered | Round-tripping the start only | 00:30 + 60 min on the fold Sunday ends at a 01:30 the record stores as the second one, so the stored meeting is two hours long (r2 A13). |
---
## 2. Data
-All migrations additive, generated by `rastrillo migration generate`.
+All migrations additive, generated.
### 2.1 `people.handle`
-```
-handle TEXT UNIQUE -- nullable; a person with no handle has no public pages
-```
-
-Plaintext. Lowercase `[a-z0-9-]{2,32}`, chosen on the settings page,
-validated on save, unique. Reserved: `m`, `new`, `static`. Changing it
-changes every public URL and the settings page says so before the field.
+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`
@@ -77,17 +78,16 @@ availability_text_sealed, published_at (nullable), created_at, updated_at
UNIQUE (person_id, code)
```
-- `code`: eight characters from `[a-z0-9]`, generated at creation, never
- editable, plaintext because it is random. The public URL is
- `/book/{handle}/{code}`.
-- `title_sealed`, `description_sealed`: content. The title heads the
- public page and names the event on the host's calendar.
-- `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, verbatim, sealed because it
- is prose. Re-parsed on every edit into §2.3.
-- `published_at`: NULL means unpublished. An unpublished page 404s
- publicly. Publishing is an explicit action with its own sentence
+- `code`: eight of `[a-z0-9]`, generated, immutable, plaintext.
+- `title_sealed` ≤ **100 bytes** of UTF-8; `description_sealed` ≤ 1000
+ bytes. Byte limits, because the event validator's limits are bytes
+ (r2 B6). The composed event title `"{title} — {name}"` is at most 100
+ + 5 + 80 bytes, under the event's 200.
+- `duration_min` 5–480; `buffer_min` 0–120 (default 10); `notice_min`
+ 0–10080 (default 120), **floor 10** — a slot is never offered less
+ than ten minutes ahead (B15); `horizon_days` 1–90 (default 30).
+- `availability_text_sealed`: the sentence, verbatim.
+- `published_at`: NULL is unpublished → 404. Publishing is explicit
(§7.3).
### 2.3 `booking_windows`
@@ -96,57 +96,84 @@ UNIQUE (person_id, code)
id, page_id (idx), weekday (0=Sunday..6), start_min, end_min
```
-Plaintext, deliberately: shape-of-time, already on the leak list.
-`0 <= start_min < end_min <= 1440`. Rewritten wholesale on every save.
-**Normalised before storage:** windows on one weekday that overlap or
-touch are merged, so `Monday 9–12, Monday 10–13` is stored as one
-`Monday 9–13` window and steps as one (finding 17).
+Plaintext (shape-of-time). `0 <= start_min < end_min <= 1440`.
+Rewritten wholesale on save, **merged** first: overlapping or touching
+windows on one weekday become one.
### 2.4 `bookings`
```
-id, page_id (idx), event_id (idx), token_hash UNIQUE, state (idx),
+id, page_id (idx), event_id (idx), token_hash UNIQUE, token_sealed, state (idx),
booker_name_sealed, booker_address_sealed, booker_address_fp (idx),
note_sealed, booker_zone,
-hold_expires_at (idx), confirmed_at, cancelled_at,
-pending_mail, mail_attempts,
-ip_fp (idx, nullable), reschedules,
+hold_expires_at (idx), confirmed_at, cancelled_at, cancelled_by,
+ip_fp (idx, nullable), ip_forget_at (idx), reschedules,
created_at, updated_at
```
-- `token_hash`: SHA-256 of 32 random bytes, exactly `HashFeedToken`.
-- `state`: one of `held`, `confirmed`, `cancelled`, `expired`, `failed`.
- The transition table is §5.7.
-- `booker_name_sealed`, `booker_address_sealed`, `note_sealed`: content.
-- `booker_address_fp`: `seal.Fingerprint(PurposeAttendeeLookup, addr)`,
- keyed, as attendees.
-- `booker_zone`: IANA name, plaintext, and ONLY ever one of
- `CommonZones` (§5.1).
-- `pending_mail`: `''`, `hold`, `confirmed`, `rescheduled`, `cancelled`
- — WHICH mail is owed, never its content. `mail_attempts` counts tries;
- after `MaxReminderAttempts` the marker clears and the failure is
- logged with a stage, not an address.
-- `ip_fp`: `seal.Fingerprint(PurposeBookingIP, ip)` of the client IP at
- hold time, for the caps in §7. **Nulled by the sweep 24 hours after
- `created_at`**, because its purpose ends there and a permanent
- network-linkage column is a leak the estate does not otherwise have
- (finding 4).
-- `reschedules`: how many times this booking has been moved; capped
- (§7).
-- **No `sequence`** (B11).
-
-### 2.5 The client IP
-
-The instance listens on a unix socket that only the platform edge can
-reach, and the edge calls `httputil.ProxyRequest.SetXForwarded`, which
-REPLACES `X-Forwarded-For` with the dialling client (`platform/
-internal/edge/homemap.go setForwarded`; on a verified mesh hop the home
-edge restores the original single-element chain). So: **the client IP
-is the LAST element of `X-Forwarded-For`**, and when the header is
-absent (tests, `rastrillo dev`) it is the host part of `RemoteAddr`.
-Canonicalise with `net.ParseIP(...).String()` before fingerprinting; an
-unparseable value is the literal string `"unknown"` and shares one
-bucket. Never logged.
+- `token_hash`: `HashFeedToken(token)`, the lookup. `token_sealed`:
+ the same token under `PurposeBookingToken` (B14), opened only to
+ build a mail.
+- `state`: `held` → `confirmed` → `cancelled`, or `held` → `expired`.
+ Four states; §5.7 is the table.
+- `booker_zone`: one of `CommonZones` **or the host's own zone** — the
+ two sources the page offers (r2 A5). Never free text.
+- `cancelled_by`: `''`, `booker`, `host` (r2 B2).
+- `ip_fp`: `Fingerprint(PurposeBookingIP, ip)`, for §7. `ip_forget_at =
+ created_at + 24h`; the sweep nulls `ip_fp` at or after it, and
+ `NextDueAt` includes the earliest `ip_forget_at` with a non-null
+ `ip_fp`, so the forgetting is scheduled, not incidental (r2 A4).
+- `reschedules`: capped at `MaxReschedules = 5`.
+
+### 2.5 `booking_mail`
+
+```
+id, booking_id (idx), kind, recipient, revision, due_at (idx),
+attempts, claimed_at, sent_at, created_at
+UNIQUE (booking_id, kind, recipient, revision)
+```
+
+- `kind`: `hold`, `confirmed`, `rescheduled`, `cancelled`.
+ `recipient`: `booker` or `host`. `revision`: the event's `sequence`
+ at the moment the row was made.
+- Rows are created **inside the transition's transaction** (§5.7).
+ `due_at = now`. The handler that made them tries to send immediately
+ (§6); the drain owns retries.
+- Claimed with the reminder queue's lease (`claimed_at`, same
+ `claimLease`), acknowledged with `UPDATE … SET sent_at WHERE id = ?
+ AND sent_at IS NULL`. `attempts` is per row. At
+ `MaxReminderAttempts`, `sent_at` is set to `-1` (gave up) and a log
+ line names `stage=booking.mail code=gave_up` and nothing else.
+- **Superseding:** when a `cancelled` row is created, every unsent
+ `rescheduled` row for the same booking is deleted in the same
+ transaction. Nothing else supersedes anything: a confirmed mail is
+ still owed after a cancel (the host should learn both).
+- No content. The body is regenerated from the sealed columns and the
+ event at send time. **What a body may contain is §5.5's allowlist.**
+
+### 2.6 Ownership contract (r2 A20)
+
+Every settings read or write of a page is `WHERE id = ? AND person_id =
+?`. A miss is `ErrNotFound` → 404. There is no route that takes a page
+id without a person. `PublicBookingPage(handle, code)` is the only
+unscoped reader and it 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 own
+duration (`end - start`), not the page's current one. A booking's
+TITLE and DESCRIPTION as shown to the booker are the page's CURRENT
+ones — display text the host owns and may correct. Both facts are
+stated here so neither is discovered.
+
+### 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`.
+Canonicalised with `net.ParseIP(...).String()`; unparseable → the
+literal `"unknown"`. Never logged.
---
@@ -154,8 +181,9 @@ bucket. Never logged.
### 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**:
+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, and a first clause with none is a problem.
```
mondays between 12–3, tuesdays 12–2, 5-7
@@ -165,466 +193,456 @@ every day 8–6
saturday 10:30–13:00; sunday 14-16
```
-- **Day part**: a weekday name in any spelling `weekdayAlt` knows,
- optionally plural, optionally `every`; a weekday RANGE with the quick
- add separators (`mon-wed`, `monday to friday`), walking forward
- modulo 7; `weekdays` (Mon–Fri); `weekends` (Sat, Sun); `every day` /
- `daily` / `all days`.
+- **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`. Each clock is `H`, `H:MM`, `Ham`, `H:MMpm`. **Raw components
- are validated BEFORE any shared helper runs**: hour `0..24`, minute
- `0..59`, and `24` only as `24:00` at the END of a range. The quick-add
- helpers clamp and forgive; here nothing is forgiven (finding 17).
- Meridiem: an explicit `am`/`pm` on either end applies to both unless
- the other says otherwise; with no meridiem anywhere the working-day
- rule applies (a bare 1–7 is afternoon; 8–12 as written), so `5-7` is
- 17:00–19:00 and `9-5` is 09:00–17:00. A range whose end is not after
- its start is **refused with its text quoted**: a booking window does
- not cross midnight.
-- **A clause with no day part inherits the previous clause's days.**
- `tuesdays 12–2, 5-7` is two windows on Tuesday. A first clause with
- no day part is a problem.
+ `till`. A clock is `H`, `H:MM`, `Ham`, `H:MMpm`. **Raw components are
+ validated first, with no clamping** (r1 f17): minute `0..59`; with a
+ meridiem the hour is `1..12` (`13pm` is a problem — r2 A17); without,
+ `0..24`, and `24` only as `24:00` at the END of a range. **Meridiem:**
+ an explicit `am`/`pm` on either end applies to both unless the other
+ says otherwise; with none on either end, the working-day rule applies
+ **to the hour component regardless of minutes**: a bare hour 1–7 is
+ afternoon (`5-7` → 17:00–19:00; `12:30-2:30` → 12:30–14:30), 8–12 as
+ written, 0 and 13–24 as written. End not after start → problem, the
+ range quoted: windows do not cross midnight.
- Filler dropped: `between`, `from`, `and`, `on`, `at`.
### 3.2 What it refuses
-Every token not consumed by a day part, a time range or a filler word
-is a problem, quoted. Every problem in the sentence is reported, not
-just the first. The page cannot be saved while any exists.
+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`, `ParseAvailability(text) (windows
-[]Window, problems []string)`, pure. Shares `consumed`, `weekdayAlt`,
-`weekdays`, `rangeSep` with quick add. Does NOT call `clock` or
-`applyMeridiem` on unvalidated input.
+`internal/calendar/avail.go` + `avail_windows.go`. `ParseAvailability(text)
+(windows []Window, problems []string)`, pure, windows merged and sorted.
+Shares `consumed`, `weekdayAlt`, `weekdays`, `rangeSep` with quick add;
+does NOT call `clock`/`applyMeridiem`.
### 3.4 The pane
-`POST /settings/booking/parse` returns a fragment listing merged windows
-in words — "Monday 12:00–15:00 · Tuesday 12:00–14:00, 17:00–19:00" —
-and every problem. `booking.js` is a debounced fetch cloned from
-`quickadd.js`. Without script, saving renders the same fragment inline
-at 422 with the form re-filled.
+`POST /settings/booking/parse` → fragment: merged 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 same fragment inline.
---
## 4. Slots
-`ComputeSlots(rule, busy, now, from, to)` is pure, in
-`internal/calendar/slots.go`. `FreeSlots` in `store_slots.go` fetches
-busy and calls it.
+`ComputeSlots(rule, busy, now, from, to)` is pure (`slots.go`);
+`FreeSlots` fetches busy and calls it (`store_slots.go`).
-1. **Bounds.** `earliest = now + notice_min`; `latest =
+1. **Bounds.** `earliest = now + max(notice_min, 10m)`; `latest =
now.In(hostLoc).AddDate(0,0,horizon_days)`. A candidate is offered
- only if `start >= earliest`, `start >= from`, `end <= to` and `end
- <= latest`. Each candidate is tested individually; clamping the
- iteration range is an optimisation, never the filter (finding 16).
-2. **Days.** Iterate host-zone calendar days from `from` to `to` by
- `AddDate(0,0,1)` on a local midnight, which is the next local
- midnight whatever the day's length.
-3. **Candidates.** For each window on that weekday, for `m` from
- `start_min` stepping `duration_min` while `m + duration_min <=
- end_min`: `start = time.Date(y, mo, d, 0, m, 0, 0, hostLoc)`.
- **Round-trip rule:** the candidate is dropped unless
- `FormatLocal(start) → ToUTC` gives back `start.Unix()` AND
- `start.Hour()*60+start.Minute() == m`. That drops the spring gap
- (the wall clock does not exist) and, on the fold, keeps exactly the
- occurrence `ToUTC` would store — the second one, per `clock.go` —
- so what is offered is what the record can hold (finding 13). `end =
- start + duration` as ELAPSED time from the instant.
-4. **Busy.** Fetched once over `[from - 1 day, to + 1 day)` so a buffer
- at the edge sees its neighbour (finding 15). From `EventsInRange(host)`
- 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** (a join, so an unswept hold
- frees its slot the moment it lapses — finding 19). **All-day
- events** are re-derived: their `StartLocal`/`EndLocal` dates through
- `DayBounds` in the HOST's zone, not their stored UTC index, which is
- midnight in the event's own (possibly empty) zone (finding 14).
- All-day NOTES are not busy, like every note.
-5. **Buffer.** Drop a candidate if `[start - buffer, end + buffer)`
- overlaps any busy interval. Half-open.
-6. Return ascending `Slot{StartUTC, EndUTC, Local}`; the page groups
- them by the BOOKER-zone date for display (finding 16), and the POST
- carries the instant, never a date.
-
-Tests pin the spring-forward and fold Sundays for `Europe/Dublin` 2026
-with windows that TOUCH the transition (00:00–04:00), to real unix
-numbers, as `clock_test.go` does.
+ iff `start >= earliest && start >= from && start < to && end <=
+ latest`. **`to` bounds the START, not the end** (r2 A16): a slot
+ that crosses the booker's midnight belongs to the day it starts on
+ and is shown there. Every inequality is tested per candidate.
+2. **Days.** Host-zone calendar days from `from` to `to`, stepping 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` = next midnight); `ToUTC(FormatLocal(start))
+ == start.Unix()`; `ToUTC(FormatLocal(end)) == end.Unix()`. A slot
+ that straddles the spring gap or the autumn fold fails one of these
+ and is not offered. What is offered is exactly what the record can
+ hold, both ends.
+4. **Busy.** Fetched once over `[from - 1 day, to + 1 day)` (r1 f15).
+ From `EventsInRange(host)` — **after the fix 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 (join). **All-day** events: take the occurrence's DATE
+ as `time.Unix(OccStartUTC).In(eventZoneOrUTC).Format(date)` through
+ `time.Unix(OccEndUTC-1)…` — the Occ instants are midnights in the
+ event's own zone, so this recovers the occurrence's dates, not the
+ master's (r2 A14) — then `DayBounds` of each date 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 A fix to the range query it depends on (r2 B3)
+
+`EventsInRange` today drops an override whose master the query did not
+select — a series that ended on 1 September with its last occurrence
+moved to 20 September is invisible on 20 September, in every view, not
+just here. Fix: override rows returned by `rangeRows` contribute their
+`SeriesID` to the set `overridesFor` loads, so pass 2 places them. A
+regression test in `occurrence_test.go` covers the finite-series case.
+Busy time is then what the calendar shows.
+
+### 4.2 Cost (r2 B7)
+
+`?day=` outside `[today, today + horizon_days]` in the booker's zone →
+404. The day list is computed from ONE `EventsInRange` over the
+horizon; a day page from ONE over that day ±1. And every public route,
+GET and POST, passes an in-process per-IP token bucket: 60 requests per
+minute per `ip_fp`, 429 beyond it. It is memory, resets on restart, and
+is the cheap gate in front of the expensive work; §7's row-based caps
+are the durable one behind it.
---
## 5. The public flow
-All public routes sit in the chi router beside `/feed/{token}.ics`:
-inside `csrf.Protect`, outside the signed-in group. GETs pass the origin
-check; POSTs from our own page carry `Sec-Fetch-Site: same-origin`.
-CSRF is not the abuse control — a script can send that header; §7 is.
-
-Every public response sets `Cache-Control: no-store` and
-`Referrer-Policy: no-referrer` (the token is in the path). The request
-path with the token WILL appear in rastrillo's access log; that is the
-same residual the feed URL has and is stated here rather than hidden.
+Public routes sit beside `/feed/{token}.ics`: inside `csrf.Protect`,
+outside the session group. Every public response 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(handle, code)` → 404 for an unknown handle, unknown
-code or unpublished page, byte-identical to the router's own 404. The
-page: title, description, duration, the host's first name (opened
-`NameSealed`) or handle, and a zone `<select>` whose options are
-**exactly `CommonZones`** — `?zone=` outside that set is ignored and
-the host's zone used. This bounds the input to `Zone()` (finding 5; the
-cache is also made concurrency-safe, §9).
+`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 if
+it is not among them**; `?zone=` outside that set → the host's zone.
No `?day=`: the next days with any free slot, as links carrying `day`
-(a BOOKER-zone date) and `zone`. With `day`: that day's slots in the
-booker's zone, each a `<form method=post>` with hidden `start` (UTC
-unix) and `zone` and a button. Pressing one posts to the same URL
-**without `name`**, and the handler renders the page again with that
-slot fixed at the top and the details form — `name`, `address`, `note`
-— beneath it, still carrying `start` and `zone` hidden. Two POSTs, no
-script. `booking.js` may only preselect the zone.
+(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 verifies the slot is
+still free and renders the page with that slot fixed and the details
+form (`name`, `address`, `note`) beneath, `start` and `zone` hidden.
+Two POSTs, no script. `booking.js` may only preselect the zone.
### 5.2 `POST /book/{handle}/{code}` with `name` — hold
-Limits: `name` 1–100 runes; `address` must match `reAddress` in full
-and normalise to one address; `note` ≤ 2000 runes; body ≤ 16 KiB
-(`http.MaxBytesReader`). Anything over → 422 with the form.
-
-Then, **inside `holdMu` (B12)**:
-1. §7 caps. Over → 429.
-2. `FreeSlots` for the slot's own day; `start` must be one of them
- exactly, or 409 "That time has just gone" with the day's remaining
- slots.
-3. One transaction: the event on the host's personal calendar — title
- `"{page title} — {name}"`, notes the booker's note, kind meeting,
- **status tentative**, TZ the host's zone, `start_local`/`end_local`
- from the instant in that zone; and the booking row, `held`,
- `hold_expires_at = now + 30m`, `pending_mail = 'hold'`, token minted
- and hashed.
-
-Then, outside the mutex: send the hold mail (§6). Success → clear
-`pending_mail`. **Definite** failure (the sender returned an error) →
-state `failed`, event soft-deleted, 422 "We could not send to that
-address"; the row stays and counts toward §7. Otherwise the response is
-200 "Check your mail" with the address echoed and the expiry in the
-booker's zone.
+Body ≤ 16 KiB (`MaxBytesReader`). `name` 1–60 runes AND ≤ 80 bytes;
+`address` a full `reAddress` match normalising to one address; `note`
+≤ 1000 runes AND ≤ 4000 bytes (r2 B6). Over → 422 with the form.
+
+Inside `holdMu` (B12): §7 caps → 429; `FreeSlots` for that day, `start`
+must be present → else 409 "That time has just gone"; then ONE
+transaction: the tentative event (title `"{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, `ip_fp`, `ip_forget_at`), and a `booking_mail` row
+(`hold`, `booker`, revision 0).
+
+Outside the mutex: try to send that row now (§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 send returned an
+error the page adds "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. Then by state:
+`BookingByToken` → 404. Reads the EVENT for status and deletion. Then:
-- `held`, `now < hold_expires_at`: a page saying what is being booked
- and when (booker zone), with ONE form: `POST /book/m/{token}/confirm`,
- button "Confirm this booking". Below it: "Didn't ask for this? Do
- nothing; it expires at …". **Nothing changes on this GET.** (B1.)
-- `held`, expired, or `expired`: "This hold expired at …", link to the
- page.
-- `confirmed`: the manage page (§5.5).
-- `cancelled`, `failed`: says so, link to the page.
+- `held` and `now < hold_expires_at`: what and when; ONE form → `POST
+ …/confirm`, button "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 …", link
+ back.
+- `confirmed` and the event is not cancelled or deleted: manage (§5.5).
+- otherwise (`cancelled`, or the host cancelled/deleted the event):
+ "cancelled", who by, link back.
### 5.4 `POST /book/m/{token}/confirm`
-Transition `held → confirmed` per §5.7, conditional on `state = 'held'
-AND hold_expires_at > now`, all in one transaction: booking state and
-`confirmed_at`; event `status = confirmed`, `sequence + 1`; attendee
-row (`SetAttendees`); the host's default reminder (`EnsureDefaultReminder`
-— finding 18); `pending_mail = 'confirmed'`. Zero rows affected → the
-state has moved; re-render §5.3 for whatever it now is. Then send the
-host mail (§6) and clear the marker. Pressing twice cannot mail twice:
-the second press affects zero rows.
-
-### 5.5 The manage page (state `confirmed`)
-
-What (page title), when (booker zone, and host zone when they differ),
-who (host name/handle and host address), and:
-- **Add to calendar** → `GET /book/m/{token}/calendar.ics`
-- **Reschedule** → `GET /book/m/{token}/reschedule`
-- **Cancel** → a form, `POST /book/m/{token}/cancel`
-
-**The public allowlist (finding 7):** every public HTML page, every
-mail to the booker and the ICS may show ONLY: page title, page
-description, the slot's times, the host's name/handle and address, the
-booker's own name and address, and the booking's state. **Never** the
-event's notes, location, or attendee list, all of which the host may
-have edited since. A test edits the event as the host after
-confirmation and asserts the additions never reach the booker.
-
-**Host authority (finding 11):** the manage page reads the EVENT. If
-the host has set the event cancelled or deleted it, the manage page
-shows cancelled and offers no reschedule; the ICS says `METHOD:CANCEL`.
-`UpdateEvent` refuses an `RRule` on an event that a booking references
-("This is a booked meeting; it cannot repeat."), and `DeleteEvent` on
-one sets the booking `cancelled` in the same transaction with
-`pending_mail = 'cancelled'` so the booker IS told when the host
-deletes — the cheapest honest version of "the host is not a silent
-authority".
+One transaction: `UPDATE bookings SET state='confirmed', confirmed_at
+WHERE id=? AND state='held' AND hold_expires_at > ? AND ? < event
+start` — the third guard read from the event row in the same
+transaction; `RowsAffected == 1` or stop and re-render §5.3. Then event
+`status='confirmed', sequence = sequence + 1`; attendee row; the
+host's default reminder; `booking_mail(confirmed, host, revision)`.
+Then try to send. A second press affects zero rows and does nothing.
+
+### 5.5 The manage page
+
+What (page title, description), when (booker zone; host zone if it
+differs), who (host name/handle, host address), and: **Add to
+calendar** (`…/calendar.ics`), **Reschedule**, **Cancel** (a form).
+
+**The public allowlist (r1 f7):** every public page, booker mail and
+the ICS may contain ONLY: page title, page description, the event's
+start and end, the event's status, the host's name/handle and address,
+the booker's own name and address, `cancelled_by`. **Never** the
+event's notes, location or attendee list. A test edits all three as the
+host after confirmation and asserts none reaches manage, ICS or mail.
+
+**Host authority (r1 f11, r2 A11, A18).** The host edits the event like
+any event, and the booking follows the event:
+- `UpdateEvent` refuses an `RRule` on an event a booking references:
+ `ValidationError{"repeat", "This is a booked meeting; it cannot
+ repeat."}`.
+- `UpdateEvent` that sets `status = cancelled` on a booked event, in
+ the same transaction: booking `confirmed → cancelled`, `cancelled_by
+ = host`, reminder queue rows for the event deleted, `booking_mail
+ (cancelled, booker)`. A `held` booking on a host-cancelled event →
+ `expired`.
+- `DeleteEvent` on a booked event: the same transitions, and `sequence
+ = sequence + 1` on the (soft-deleted) event so the CANCEL ICS carries
+ a higher revision than the REQUEST did.
+- `UpdateEvent`'s `sequence` bump becomes `gorm.Expr("sequence + 1")`
+ everywhere; the stale `existing.Sequence + 1` goes.
+- The reminder claim path drops a queue row whose event is `cancelled`
+ (today it checks only `deleted_at`).
### 5.6 Reschedule and cancel
-`GET …/reschedule`: the picker from §5.1 with the booking's own event
-excluded from busy, posting to `POST …/reschedule` with `start`, `zone`.
-Inside `holdMu`: `reschedules < MaxReschedules` (§7), `FreeSlots` check,
-then one transaction: event `start_local`/`end_local`/UTC index through
-the same explicit column allowlist `UpdateEvent` uses, `sequence + 1`;
-`RescheduleReminder` for every reminder on the event; booking
-`reschedules + 1`, `pending_mail = 'rescheduled'`. Then mail both
-parties.
-
-`POST …/cancel`: conditional on `state = 'confirmed'`: event `status =
-cancelled`, `sequence + 1`; reminder queue rows for the event deleted;
-booking `cancelled`, `cancelled_at`, `pending_mail = 'cancelled'`. Mail
-both. A second press affects zero rows and mails nobody.
+`GET …/reschedule`: the §5.1 picker with the booking's event excluded
+from busy, and a hidden `rev` = the event's current `sequence`. `POST
+…/reschedule` with `start`, `zone`, `rev`. Inside `holdMu`: state
+`confirmed`; `reschedules < MaxReschedules` (else 429); `FreeSlots`
+check with the EVENT's duration; then one transaction: `UPDATE events
+SET start_local, end_local, start_utc, end_utc, sequence = sequence +
+1, updated_at WHERE id = ? AND sequence = ?` (the `rev` — a resubmitted
+form affects zero rows and re-renders, r2 A10); booking `reschedules =
+reschedules + 1`; `RescheduleReminder` per reminder; two `booking_mail`
+rows (`rescheduled`, booker + host, new revision). Then try both.
+
+`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; unsent `rescheduled` mail rows deleted; two `booking_mail`
+rows (`cancelled`). A second press affects zero rows.
### 5.7 Transitions
-| From | Event | To | Guard | Side effects (same transaction) |
+| From | Trigger | To | Guard (in the UPDATE) | Same transaction |
|---|---|---|---|---|
-| — | hold | `held` | caps; slot free; inside `holdMu` | tentative event; `pending_mail='hold'` |
-| `held` | send failed | `failed` | definite sender error | event soft-deleted |
-| `held` | confirm POST | `confirmed` | `hold_expires_at > now` | event confirmed, seq+1; attendee; default reminder; `pending_mail='confirmed'` |
-| `held` | sweep | `expired` | `hold_expires_at <= now` | event soft-deleted |
-| `confirmed` | reschedule | `confirmed` | slot free; `reschedules < max`; inside `holdMu` | event moved, seq+1; reminders rescheduled; `pending_mail='rescheduled'` |
-| `confirmed` | cancel (booker) | `cancelled` | — | event cancelled, seq+1; queue rows deleted; `pending_mail='cancelled'` |
-| `confirmed` | host deletes event | `cancelled` | — | `pending_mail='cancelled'` |
-| any | sweep, 24h after created | same | — | `ip_fp = NULL` |
-
-Every transition is a conditional `UPDATE … WHERE id = ? AND state = ?`
-(plus the guard) and checks `RowsAffected == 1`. Zero rows means
-somebody else moved it first; the caller re-reads and renders, never
-retries the transition. No transition sends mail inside its
-transaction; the marker does.
+| — | hold | `held` | caps; slot free; `holdMu` | tentative event; `mail(hold, booker, 0)` |
+| `held` | confirm POST | `confirmed` | `hold_expires_at > now AND now < event.start_utc` | event confirmed, seq+1; attendee; default reminder; `mail(confirmed, host, seq)` |
+| `held` | sweep | `expired` | `hold_expires_at <= now` | event soft-deleted; queue rows deleted |
+| `held` | host cancels/deletes event | `expired` | — | (event already handled by the host's call) |
+| `confirmed` | reschedule | `confirmed` | slot free; `reschedules < 5`; `holdMu`; `events.sequence = rev` | event moved, seq+1; reminders rescheduled; `reschedules+1`; `mail(rescheduled, both, seq)` |
+| `confirmed` | booker cancels | `cancelled` | `state='confirmed'` | event cancelled, seq+1; queue rows deleted; unsent `rescheduled` mail deleted; `cancelled_by=booker`; `mail(cancelled, both, seq)` |
+| `confirmed` | host cancels/deletes event | `cancelled` | `state='confirmed'` | seq+1; queue rows 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; the row is.
---
## 6. Mail
-Plain text through `mail.Sender`, which has one configured sender and
-no per-message From: the host's address is in the BODY (and, for the
-booker, in the ICS as ORGANIZER), never claimed as the envelope sender
-(finding 21). Bodies are `text/template` files in `templates/mail/`.
-No booker content in any log line.
+Plain text through `mail.Sender`, one configured sender, no per-message
+From: the host's address is in the BODY (and as `ORGANIZER` in the
+ICS). Bodies are `text/template` files in `templates/mail/`, rendered
+at send time from the booking, the page, the event (allowlisted fields
+only) and the opened token. No booker content in any log line.
-| `pending_mail` | To | Says |
+| kind | to | Says |
|---|---|---|
| `hold` | booker | "Somebody — we hope you — asked to book {title} with {host} on {when, booker zone}. If that was you, confirm it here: {link}. If not, do nothing; it expires at {expiry}." |
| `confirmed` | host | "{name} ({address}) booked {title}, {when, host zone}. Note: {note}. It is on your calendar: {event link}." |
-| `rescheduled` | booker AND host | The new time in each 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 AND host | What was cancelled, when it had been, who cancelled (booker or host). |
-
-**Delivery (B13):** after the transition commits, the handler sends
-once. Success clears `pending_mail`. Failure leaves it and bumps
-`mail_attempts`; the drain (§8) retries any row with a marker,
-regenerating the body from the sealed columns, up to
-`MaxReminderAttempts`, then clears it and logs `stage=booking.mail
-code=gave_up`. The hold mail is the one exception: a definite failure
-there moves the row to `failed` (§5.2), because a hold nobody can
-confirm is a stolen slot. An ambiguous failure — the sender returned
-success but the mail never arrived — is not detectable and the hold
-simply expires.
-
-The `mail.Sender` ignores its context. Wrap every send in
-`context.WithTimeout(ctx, 20*time.Second)` anyway so a future sender
-that honours it is bounded, and say in a comment that today's does not.
+| `rescheduled` | booker, host | The new 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, and `cancelled_by` in words. |
+
+**Sending.** `sendBookingMail(row)` renders and calls a `timedSender`
+that runs `Send` in a goroutine and waits at most 20 seconds, returning
+`ErrMailTimeout` and abandoning the goroutine otherwise — the only way
+to bound a sender that ignores its context, and the abandoned send is
+still a send, so at-least-once holds (r2 B4). Success → `sent_at`.
+Error → `attempts + 1`, `claimed_at` refreshed so the lease expires
+and the drain retries. Nothing is ever concluded from an error except
+"try again later" (B16).
---
## 7. Abuse
-None of these needs a dependency. All are counted from `bookings` rows,
-which persist through every state including `failed` — that is why
-failures are rows, not rollbacks (finding 3).
-
-### 7.1 Caps, checked before any slot arithmetic
+### 7.1 Durable caps, from `bookings` rows, checked 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`, any state | 20 |
-| Attempts | `booker_address_fp` | `created_at > now - 24h`, any state | 6 |
-| Reschedules | booking | lifetime | `MaxReschedules = 5` |
-
-Over any cap: 429, "Too many bookings in progress. Try again later."
-The `ip_fp` caps stop applying to a row when the sweep nulls its
-`ip_fp` at 24 hours, which is exactly when the attempt window closes.
-
-### 7.2 What this does not stop
-
-A patient adversary with many mailboxes and many addresses can still
-book real slots one at a time, confirm them, and waste the host's
-time. So can a patient human with a phone. The host cancels them like
-any other event and, if it continues, unpublishes the page. Nothing
-here claims to stop a determined person; it stops a script from
-emptying a calendar or sending mail in a loop.
-
-### 7.3 The oracle, stated as new
-
-A published page lets **anyone on the internet** ask when this host is
-free, without holding the database. The existing leak list is about
-what a published FILE gives up; this is a live query, and because every
-granted calendar counts it also reflects the team calendar's shape
-(finding 8). So publishing is a deliberate act: the toggle is labelled
-**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, worded as a new disclosure, not an existing one.
+| 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 cheap gate (r2 B7)
+
+An in-process token bucket per `ip_fp`, 60 requests/minute, on every
+public route including GETs and the name-less POST. 429. Memory only.
+
+### 7.3 What this does not stop
+
+A patient adversary with many mailboxes and addresses books real slots
+one at a time. So does a patient human. The host cancels them and, if
+it continues, unpublishes. Nothing here stops a determined person; it
+stops a script.
+
+### 7.4 The oracle, stated as new (r1 f8)
+
+A published page lets anyone ask when this host is free, without the
+database, and every granted calendar shapes the answer. Publishing is a
+deliberate act: 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
+## 8. The sweep (r2 A4, A19, B4)
+
+`drain` (the tick) runs, in order, inside the existing 45-second
+`drainDeadline` shared by everything:
-`drain` (tick and `DrainOnRequest`) runs three bounded steps, each
-independent of whether the others found work (finding 19):
+1. **Expire holds** (cheap): `held AND hold_expires_at <= now` →
+ `expired`, event soft-deleted, queue rows deleted. ≤ 200.
+2. **Reminders**, exactly as today, until `deadline/2` or the queue is
+ empty — so reminders make progress whatever the mail queue holds.
+3. **Booking mail**: claim rows `sent_at IS NULL AND due_at <= now`
+ with an expired lease, oldest first, until the deadline; each send
+ bounded by `timedSender`.
+4. **Forget IPs**: `ip_fp IS NOT NULL AND ip_forget_at <= now` → NULL.
+ ≤ 500.
-1. **Expire holds**: `held AND hold_expires_at <= now` → `expired`,
- event soft-deleted, reminder queue rows for it deleted. Bounded to
- 200 per pass.
-2. **Owed mail**: up to 20 rows with `pending_mail <> ''`, oldest
- first; send, clear or bump `mail_attempts`.
-3. **Forget IPs**: `ip_fp IS NOT NULL AND created_at <= now - 24h` →
- `NULL`. Bounded to 500.
-4. Then the reminder loop as today.
+`DrainOnRequest` (a signed-in page view) does ONE thing: a due
+reminder if any, else one booking mail, else one expiry. Never more.
-`NextDueAt` returns the EARLIEST of: the next reminder `due_at`; the
-next `hold_expires_at` among `held` rows; `now` if any `pending_mail`
-is set. It is what the platform wakes the instance for, so a pending
-mail wakes it now and a hold wakes it at expiry.
+`NextDueAt` = the earliest of: next reminder `due_at`; next `held`
+`hold_expires_at`; next `booking_mail.due_at` with `sent_at IS NULL`;
+next `ip_forget_at` with `ip_fp IS NOT NULL`.
---
## 9. Testing
-Enforced, not remembered.
-
-- **Parser** (`avail_test.go`): every example in §3.1; inheritance; a
- first clause with no day; midnight refusal; `25:00`, `9:60`, `24:00`
- at start vs end; plural/singular; `every`; `weekdays`/`weekends`;
- each separator; the en dash; three ranges; two problems both
- reported; overlapping windows merged.
-- **Slots** (`slots_test.go`): the 12–3 / 60-min arithmetic with and
- without a 10-min buffer (12:00 and 13:00 go, 14:00 stays); tentative
- blocks; a lapsed-but-unswept hold does not; cancelled does not; a note
- does not; an all-day commitment blocks the HOST's whole day even when
- the host is in `Asia/Tokyo` and the event's zone is empty; notice
- hides the near future per-candidate; horizon ends the list; a busy
- block ending five minutes before `from` still removes the first slot
- when buffered; the spring-forward and fold Sundays with a 00:00–04:00
- window pinned to real unix numbers; a Tokyo Monday slot displays on a
- Los Angeles Sunday and books the right instant.
-- **Concurrency**: 20 goroutines hold the same slot at once; exactly one
- succeeds, the rest get `ErrSlotTaken`. Run with `-race`.
-- **Flow** (`booking_http_test.go`): through the router with
- `pickyMailer`. Hold → one mail; the link's GET changes nothing (state
- still `held`, no host mail); the confirm POST confirms, records the
- attendee, creates the default reminder, mails the host once; a second
- POST affects nothing and mails nobody; the same slot cannot be held
- twice (409); wrong token 404 byte-identical to a route 404; expired
- says expired; the ICS has `METHOD:REQUEST`, `ORGANIZER`, `ATTENDEE
- …ACCEPTED`, `SEQUENCE:` equal to the event's, NO `URL:`, and parses
- with the app's own importer; reschedule bumps the EVENT sequence and
- mails both; cancel gives `METHOD:CANCEL`; **the host adds a note,
- a location and a second attendee after confirmation, and none of the
- three appears in the manage page, the ICS or any booker mail**; the
- host deletes the event and the booker's page shows cancelled and the
- booker is mailed; `UpdateEvent` refuses an RRule on a booked event.
-- **Mail outbox**: a refused host mail leaves `pending_mail='confirmed'`;
- `tick` retries it; `NextDue` is `<= now` while it is owed.
-- **Caps**: the sixth open hold from one IP 429s; expiry frees it; the
- 21st attempt in 24h 429s even though every earlier hold failed;
- `MaxReschedules` is enforced.
-- **Sweep**: `tick` expires a hold, deletes its event and reminder
- rows, frees the slot; nulls `ip_fp` after 24h; `NextDueAt` returns
- the hold's expiry when it is sooner than any reminder.
-- **`Zone()`**: 100 goroutines resolve 100 distinct invalid names
- concurrently under `-race`; the cache holds no failures afterwards.
-- **The one rule**: `TestTheDatabaseNeverHoldsAPlaintextTitle` gains a
- booking fixture — booker name, address, note, page title, description,
- availability text, token — grepped from the raw bytes.
-- **Logs**: a `slog` handler over a buffer; a failed send; the buffer
- has no address and no token.
-- **Browser** (`make browser`): a stranger with JavaScript OFF books a
- slot end to end, reads "check your mail", opens the recorded link,
- presses Confirm, sees Reschedule. With JS on, the settings pane fills
- in from the availability text.
+- **Parser**: every §3.1 example; inheritance; first clause without a
+ day; midnight; `25-26`, `9:60`, `13pm`, `24:00` at 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; a busy block 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, not the master's; an all-day event with empty zone blocks the
+ host's whole day in `Asia/Tokyo`; notice floor of 10 min; horizon
+ bounds `end`; `to` bounds start not end (a slot crossing booker
+ midnight is offered on its start day); spring-forward and fold
+ Sundays with a 00:00–04:00 window: no offered slot straddles the
+ transition, every offered start AND end round-trips through `ToUTC`,
+ pinned unix numbers.
+- **Range query**: a finite series whose last occurrence was moved past
+ `UNTIL` appears in `EventsInRange` for the moved day (new regression
+ in `occurrence_test.go`).
+- **Concurrency**: 20 goroutines, one slot, one winner, `-race`.
+- **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`; reschedule with the right
+ `rev` bumps sequence and mails both; the SAME form resubmitted (stale
+ `rev`) affects nothing; cancel → `METHOD:CANCEL`, unsent rescheduled
+ 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, and the CANCEL ICS has a
+ higher sequence; RRule refused on a booked event.
+- **Mail queue**: kill-before-send simulated by inserting the row and
+ running `tick` — the link in the sent mail works (the token was
+ recoverable); a refused host mail is retried by `tick` and gives up
+ at `MaxReminderAttempts` with `sent_at = -1`; a hold whose send
+ errors keeps its slot; two drains do not send one row twice (claim
+ lease); `timedSender` returns within 20 s against a stalling sender.
+- **Caps and gate**: sixth open hold per IP → 429; expiry frees it;
+ 21st attempt in 24h → 429 with none open; `MaxReschedules`; the 61st
+ public GET in a minute → 429; `?day=` past the horizon → 404.
+- **Sweep**: `tick` expires, soft-deletes, frees; retries owed mail;
+ nulls `ip_fp` at `ip_forget_at`; `NextDueAt` returns each of the four
+ sources when it is the earliest; `DrainOnRequest` does one thing.
+- **`Zone()`**: 100 goroutines × garbage names under `-race`; the
+ cache holds no failures.
+- **Reminders**: a cancelled event's queue row is dropped by the claim
+ path.
+- **The one rule**: booker name, address, note, page title, description,
+ availability, and the token — 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, said so nobody reads it in
+## 10. What is NOT in this version
-- **Group / round-robin pages.**
-- **Booker questions / custom fields.**
-- **Host-initiated reschedule from the manage flow.** The host edits
- the event like any other; the booker is told only on DELETE (§5.5).
- An edit that moves the time does not mail the booker, and the event
- page says so beside the booking badge: "Moving this here does not tell
- {name}. Cancel and let them rebook, or tell them yourself."
+- Group / round-robin pages; booker custom fields; payments; video
+ links; reminders to the booker.
+- **Host-initiated reschedule that tells the booker.** A host edit that
+ moves the time does not mail the booker; the event page says so
+ beside the booking badge. Cancel and delete DO tell the booker (§5.5).
- **Automatic calendar sync.** The ICS is a download of the current
- state. A recipient who imported it re-imports after a change mail; the
- mail says so.
-- **Payments, video links, reminders to the booker.**
-- **Locale.** English strings, like the rest of the app.
-- **Retention.** Cancelled and expired rows stay, sealed, minus `ip_fp`.
+ state; the change mail says to download it again.
+- Locale; retention (rows stay, sealed, minus `ip_fp`).
---
## 11. Changes to AGENTS.md in the same commit
-- Settled decisions: rows for B1, B2, B4, B5, B8, B11, B12, B13.
-- Frontend: after "Attendees are recorded, never invited": **"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 and note are sealed; the address has
- the keyed fingerprint. A page's public code is generated, never a
- chosen slug — a slug is prose. Booking windows are plaintext: they are
- shape-of-time."**
-- Data: **"Every booking transition is a conditional UPDATE on state
- that checks RowsAffected. Zero rows is 'someone moved it first', never
- a retry."**
-- Reminders → renamed **Ticks**: **"`NextDue` is the earliest of the
- next reminder, the next hold expiry, and now-if-mail-is-owed. A new
- kind of scheduled work goes here, never in a goroutine."**
-- The one rule's exclusions, new item: **"5. A PUBLISHED booking page
- is a live free/busy oracle for that host, to anyone, without the
- database. This is a new disclosure and the publish toggle says so."**
-- Mechanical rules → Time: **"`Zone()` takes bounded input on public
- routes: `CommonZones` only."**
+- Settled decisions: rows B1, B2, B4, B5, B8, B11, B12, B13, B14, B15,
+ B16, B17.
+- 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."**
+- Reminders → **Ticks**: **"`NextDue` is the earliest of the next
+ reminder, the next hold expiry, the next owed booking mail, and the
+ next IP-forget. New scheduled work goes here, never in a goroutine.
+ Reminders get the first half of the drain deadline."**
+- Reminders: **"No sender error is definite. Never free a resource
+ because a send 'failed'."**
+- 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.
---
## 12. Review
-**Round 1 — GPT-6 via `codex-cli 0.153.0`, 2026-09-07. Verdict:
-REWRITE.** 21 findings. Dispositions:
+Reviewer both rounds: **`gpt-6-astra`** via `codex-cli 0.153.0`,
+2026-09-07.
+
+**Round 1: REWRITE, 21 findings, all accepted** (dispositions were in
+revision 2 and are folded into the text above; the round-2 table below
+records what still needed work).
+
+**Round 2: REWRITE.** Of the 21: 12 HOLD; 7 PARTIAL; 4 DO NOT HOLD.
+Plus 7 new. Dispositions:
-| # | Finding | Disposition |
+| Ref | Finding | Disposition |
|---|---|---|
-| 1 | Plaintext slugs are prose joined to events | **Accepted.** Generated 8-char codes (B4, §2.2). |
-| 2 | GET-confirms is confirmed by mail-gateway prefetch | **Accepted, and it was the worst thing in v1.** GET renders a button; POST confirms (B1, §5.3–5.4). |
-| 3 | Open-hold caps do not bound mail abuse | **Accepted.** Attempt caps over 24h counting every state incl. `failed`; `MaxReschedules` (§7). |
-| 4 | Named IP accessor does not exist; permanent linkage | **Accepted.** Source is the edge's `SetXForwarded`, last XFF element, verified in platform source (§2.5); `ip_fp` nulled at 24h. |
-| 5 | `Zone()` cache is an unsynchronised map that caches failures, fed by public input | **Accepted, and it is a pre-existing bug.** Public zone bounded to `CommonZones`; cache made safe and failures uncached; concurrent test (§5.1, §9). |
-| 6 | ICS carries the management capability | **Accepted.** No `URL:`; `no-store`; `no-referrer` (B6, §5). |
-| 7 | Host's later private edits leak to the booker | **Accepted.** Explicit public allowlist and a test that edits then checks (§5.5). |
-| 8 | The oracle is a NEW disclosure | **Accepted.** Reworded; explicit Publish with the sentence (§7.3, §11). |
-| 9 | Two strangers hold one slot | **Accepted.** Process mutex + in-mutex check + 20-goroutine race test (B12). |
-| 10 | Transitions not atomic | **Accepted.** Transition table, conditional updates, RowsAffected (§5.7). |
-| 11 | Two sequence counters; host edits undefined | **Accepted.** Event sequence only; host cancel/delete flows through; RRule refused on booked events (B11, §5.5). |
-| 12 | Mail and rows cannot roll back together | **Accepted.** Commit then send; `pending_mail` outbox retried by the drain; `failed` state for a definite hold-mail failure (B13, §6). |
-| 13 | Fold example wrong; duration semantics undefined | **Accepted.** Round-trip rule; elapsed duration; window-touching-transition tests (§4.3). |
-| 14 | All-day busy from UTC index is wrong in other zones | **Accepted.** Re-derived through `DayBounds` in the host zone (§4.4). |
-| 15 | Buffer needs busy outside the range | **Accepted.** Envelope ±1 day, Occ fields (§4.4). |
-| 16 | Clamping is not filtering; `day` zone ambiguous | **Accepted.** Per-candidate inequalities; `day` is booker-zone; POST carries the instant (§4.1, §4.6). |
-| 17 | Quick-add helpers forgive malformed clocks; overlapping windows | **Accepted.** Raw validation first; windows merged (§2.3, §3.1). |
-| 18 | Reminders not wired into transitions | **Accepted.** Default reminder at confirm; reschedule/cancel/expire touch the queue (§5.4–5.7). |
-| 19 | Sweep after an early-exit loop never runs; unswept holds still block | **Accepted.** Three independent steps before the loop; lapsed holds excluded from busy by join (§4.4, §8). |
-| 20 | Forms underspecified; settings ownership; terms snapshot | **Accepted.** Two-step scriptless form with limits (§5.1–5.2); 404 outside ownership (implementation plan); a booking's terms live on its event, page edits do not touch existing events. |
-| 21 | ICS "updates in place" overclaims; From is fixed; METHOD placement | **Accepted.** Download semantics stated; host address in body/ORGANIZER; METHOD on VCALENDAR (B6, §6, §10). |
-
-Rejected: none. Cut from v1 as a result: chosen slugs, the booking
-sequence, the `URL:` line.
-
-**Round 2** — to be recorded below after the revised spec is reviewed.
+| A4 | IP forgetting not scheduled | **Accepted.** `ip_forget_at` column in `NextDueAt` (§2.4, §8). |
+| A5 | Host zone may be outside `CommonZones` | **Accepted.** Booker zone ∈ `CommonZones` ∪ {host zone} (§2.4, §5.1). |
+| A10 | Reschedule replay applies twice | **Accepted.** Hidden `rev`; `WHERE sequence = rev` (B11, §5.6). |
+| A11 | Host cancel not a transition; held-delete absent; stale `+1` | **Accepted.** All three in §5.5/§5.7; `gorm.Expr` everywhere. |
+| A12 | Sender errors are not definite | **Accepted — B16.** No `failed` state; the hold stands; the queue retries. |
+| A13 | End not round-tripped; elapsed ≠ wall | **Accepted — B17.** Four-part check; straddling slots not offered. |
+| A14 | All-day via master dates | **Accepted.** Dates recovered from the Occ instants in the event's zone (§4.4). |
+| A16 | `end <= to` loses midnight-crossers | **Accepted.** `to` bounds start (§4.1). |
+| A17 | `13pm`; colon-form afternoon rule | **Accepted.** Hour 1–12 with meridiem; hour-component rule stated (§3.1). |
+| A18 | Host cancel leaves reminders | **Accepted.** Queue rows deleted; claim path checks status (§5.5). |
+| A20 | Ownership unspecified; terms snapshot contradiction | **Accepted.** §2.6. |
+| A21 | ICS export contract missing | **Accepted.** `WriteBookingICS` is its own writer; `WriteICS` untouched (§5.5, plan). |
+| B1 | Queue cannot rebuild the link | **Accepted — B14.** Token sealed as well as hashed. |
+| B2 | One marker loses mail; no lease; no actor | **Accepted — B13.** `booking_mail` table; `cancelled_by`. |
+| B3 | Range query drops moved overrides of finished series | **Accepted, and it is a calendar bug.** Fixed in `EventsInRange` with a regression (§4.1). |
+| B4 | Mail can block pages and starve reminders | **Accepted.** Reminders first half of the deadline; `timedSender`; `DrainOnRequest` does one thing (§6, §8). |
+| B5 | Confirm after the meeting | **Accepted — B15.** Expiry = min(30m, start); confirm guard; notice floor 10 min. |
+| B6 | Public limits exceed event limits | **Accepted.** Byte limits: title 100, name 80, note 4000 (§2.2, §5.2). |
+| B7 | Public reads unbounded | **Accepted.** `day` validated; one range query per page; per-IP token bucket (§4.2, §7.2). |
+
+Rejected: none.
+
+**Round 3** — to be recorded below.