oficina / calendar Public

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>
Paul Campbell pushed by paul@keymail.dev 5f79e4e45efaeb37daa4ae2f521b6fe901b31138 parent 313abae
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.