booking: spec revision 8 after the seventh review
A fourth calendar bug: advance steps from each occurrence's RESOLVED time, so a daily 01:30 meeting that lands in the spring gap starts at 02:30 for the rest of its life. Two transaction boundaries: the hold's final busy check has to run inside the transaction that inserts, behind SQLite's write lock, or an ordinary CreateEvent by the host lands between them; and reschedule must enumerate reminder RULES, because a fired reminder has no queue row to rebuild.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+61
−18
docs/superpowers/specs/2026-09-07-booking-design.md+61 −18
diff --git a/docs/superpowers/specs/2026-09-07-booking-design.md b/docs/superpowers/specs/2026-09-07-booking-design.md| index 416cdb9..034670a 100644 |
| --- a/docs/superpowers/specs/2026-09-07-booking-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-booking-design.md |
| @@ -4,9 +4,11 @@ |
| 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 |
| -7.** Reviewed six times by GPT-6 (`gpt-6-astra` on the one run that |
| -exposed its identifier) through `codex-cli 0.153.0`; all six returned |
| -REWRITE, each round narrower than the last — round 6 found three. §12 records every finding |
| +8.** Reviewed seven times by GPT-6 (`gpt-6-astra` on the one run that |
| +exposed its identifier) through `codex-cli 0.153.0`; all seven returned |
| +REWRITE, each round narrower than the last — rounds 6 and 7 found three |
| +each, and round 7's were a fourth pre-existing calendar bug and two |
| +transaction boundaries. §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 |
| @@ -52,7 +54,8 @@ first — and is swept on the existing tick. |
| | 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** | The write transaction alone | SQLite begins deferred (r1 f9). | |
| +| 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). | |
| @@ -310,6 +313,17 @@ time must be what the calendar shows. |
| 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. |
| - **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` |
| @@ -374,9 +388,16 @@ 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, |
| -`start` present → else 409 "That time has just gone"; then ONE |
| -transaction: the tentative event (title `"{page title} — {name}"`, |
| +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, |
| @@ -479,16 +500,21 @@ and the rest (title, notes, location) the booker never sees. |
| 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; one transaction: |
| -`UPDATE events SET start_local, end_local, tz, start_utc, end_utc, |
| -sequence = sequence + 1 WHERE id = ? AND sequence = ?` (rev) — **all |
| +`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 every reminder queue row for the event is rebuilt in the |
| -same transaction** (a `tx` form of `rescheduleAfter` that recomputes |
| -`due_at` from the new start, clears `claim_id`, `claimed_at` and |
| -`retry_at`) (B24). Try both mails after commit. |
| +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 |
| @@ -699,14 +725,19 @@ NULL`. |
| 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; the final occurrence across the fold |
| + 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); 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`. |
| +- **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 |
| @@ -757,7 +788,9 @@ NULL`. |
| 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 reschedule that lands between |
| + 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 |
| @@ -919,4 +952,14 @@ Rejected: none. |
| Rejected: none. |
| -**Round 7** — to be recorded below. |
| +**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** — to be recorded below. |