oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1# Booking Pages Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** A public "book time with me" page per host, with plain-text availability, a mailed link whose button confirms, a tokenised ICS download, and booker-side reschedule and cancel.
6
7**Architecture:** Booking pages and their parsed windows are new tables owned by a person. A hold is a TENTATIVE event on the host's personal calendar plus a `bookings` row keyed by a hashed (and sealed) token; the existing range query makes a hold block a slot. Hold creation and rescheduling serialise on one process-wide mutex. Every state change is a conditional UPDATE naming the state and revision it expects and checking `RowsAffected`. Mail is a queue table with a claim lease — the reminder queue's own pattern — retried by the existing drain. Public routes live beside `/feed/{token}.ics`: inside CSRF, outside the session group.
8
9**Tech Stack:** Go, rastrillo v0.23.0 (chi, GORM + modernc SQLite, `mail.Sender`, `csrf.Protect`, `carlos.Tick`), html/template + text/template, Playwright for the browser gate.
10
11**Spec:** `docs/superpowers/specs/2026-09-07-booking-design.md` **revision 6**. Read it first, in full, including §12. Tasks 1–3 below were written against revision 3 and Task 1 has landed; the section **"Revision 4–6 addenda"** at the end of this plan amends Tasks 3–8 and is BINDING over the task text where they differ. Every task cites the sections it implements. Where this plan and the spec disagree, the spec wins and this plan is wrong — fix the plan in the same commit.
12
13## Global Constraints
14
15Copied from AGENTS.md and the spec. Every task inherits them.
16
17- **The one rule.** Booker name, address, note, the token, page title, description and availability text are SEALED (`s.seal.SealString`). The booker address ALSO gets `s.seal.Fingerprint(seal.PurposeAttendeeLookup, addr)`. Windows, `booker_zone`, `code`, `ip_fp`, states, revisions and instants are plaintext.
18- **No title, place, note, address or token in any log line, error string or metric label.** `scrubErr(err)` on every logged error, with a `"stage"` key.
19- **A row that isn't yours 404s, never 403s.** Unknown handle, code or token → the same body the router gives an unknown route.
20- **Never bind a request body onto a model.** Explicit `Input` structs; explicit column allowlists in every `Updates`.
21- **Every booking transition is a conditional UPDATE** (`WHERE id = ? AND state = ?` plus its guard) checking `RowsAffected == 1`. Zero rows → re-read and render, never retry. Sequence bumps are `gorm.Expr("sequence + 1")`, never `existing.Sequence + 1`.
22- **Hold and reschedule run inside `Store.holdMu`** (`sync.Mutex` on `Store`), with caps and the slot check inside the lock.
23- **No sender error is definite.** Nothing is freed, deleted or marked failed because `Send` returned an error. Attempts go up; the lease expires; the drain retries.
24- **Never `sessions.UserID`.** `CurrentPerson(r)`.
25- **Migrations are generated.** `rm internal/calendar/migrations/schema.sql`; add models to `Models`; `go run github.com/carlosframework/rastrillo/cmd/rastrillo migration generate`; then `... migration check`.
26- **300-line cap on every `.go` and `.js`.** Split by responsibility.
27- **`rst-*` attribute grammar**, never class spellings, except the seven class-only utilities. This app's components take `cal-`. Rastrillo partials: `field-text`, `field-textarea`, `field-select`, `field-check`, `form-foot`, `callout`, `page-header`, `detail-list`, `empty-state`, `badge` — `settings.html` shows the `dict` convention; `~/go/pkg/mod/github.com/carlosframework/rastrillo@v0.23.0/ui/partials/*.html` has the keys.
28- **No affordance may require script.** `booking.js` is a debounced fetch plus a zone preselect.
29- **Every range is half-open.** `DayBounds`, never `Add(24h)`.
30- **Public zone input** is `CommonZones` ∪ {host zone}.
31- **Work inside the request.** The one goroutine allowed is `timedSender`'s, and the request WAITS on it (§6).
32- **`make ci` before every commit** (sandbox off if the go cache is read-only). Trailer: `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`.
33- **Tests** in `internal/calendartest` unless they need unexported names. Helpers: `signedIn`, `getAs`, `postAs`, `follow`, `newAppWithMailer(t, *pickyMailer)`, `tick(t, h)`, `app.LastMailForTest()`, `app.GrepDatabaseForTest`, `app.DBForTest()`, `app.SignInForTest`, `calendar.RequestForTest(session, method, target, form)`. `pickyMailer.Send` refuses when `subject+body` contains `refuse`; Task 6 extends it to also match `to` and records `to` per delivery.
34
35---
36
37## File map
38
39| File | Responsibility | Task |
40|---|---|---|
41| `internal/calendar/models_booking.go` | `BookingPage`, `BookingWindow`, `Booking`, `BookingMail`, `Window`, constants | 1 |
42| `internal/calendar/models.go`, `seal/seal.go`, `testing.go` | `Person.Handle`; register; purposes; test hooks | 1 |
43| `internal/calendar/migrations/0005_*.sql`, `schema.sql` | generated | 1 |
44| `internal/calendar/store_booking_page.go` | handle, page CRUD, publish, public lookup, ownership | 1 |
45| `internal/calendar/clock.go`, `zones.go` | `Zone()` safe; `IsCommonZone` | 2 |
46| `internal/calendar/avail.go`, `avail_describe.go`, `templates/_bookingpane.html` | parser; merge/describe; pane | 2 |
47| `internal/calendar/store_range.go` | override-of-finished-series fix | 3 |
48| `internal/calendar/slots.go`, `store_slots.go` | `ComputeSlots`; `BusyFor`; `FreeSlots` | 3 |
49| `internal/calendar/store_booking_flow.go` | hold, confirm, reschedule, cancel | 4 |
50| `internal/calendar/store_booking_read.go` | `BookingByToken`, `BookingView`, counts | 4 |
51| `internal/calendar/store_booking_mail.go` | queue, claim, ack, `NextBookingMailDue` | 4 |
52| `internal/calendar/store_booking_sweep.go` | expire, forget IPs, `NextHoldExpiry`, `NextIPForget` | 4 |
53| `internal/calendar/store_reminder_next.go`, `store_drain.go`, `store_event.go`, `store.go` | `NextDueAt` four sources; claim path checks status; booked-event rules; `holdMu` | 4 |
54| `internal/calendar/handlers_booking_settings.go`, `handlers_booking_form.go`, templates, `booking.js` | host UI | 5 |
55| `internal/calendar/handlers_booking_public.go`, `handlers_booking_manage.go`, `handlers_booking_util.go` | public flow; IP; bucket; 404 | 6 |
56| `internal/calendar/booking_ics.go`, `booking_mail.go`, `mail_timed.go`, `templates/mail/*.txt` | ICS; mail rendering; `timedSender` | 6 |
57| `internal/calendar/templates/book_*.html` | public UI | 6 |
58| `internal/calendar/handlers_jobs.go` | drain ordering; `DrainOnRequest` one thing | 7 |
59| `internal/calendar/routes.go`, `render.go`, `templates/layout.html`, `static/calendar.css` | wiring | 5, 6 |
60| tests: `booking_store_test.go`, `zone_test.go`, `avail_test.go`, `slots_test.go`, `occurrence_test.go` (+1), `booking_flow_test.go`, `booking_mail_test.go`, `booking_http_test.go`, `booking_abuse_test.go`, `booking_sweep_test.go`, `events_test.go` (+fixture) | | 1–7 |
61| `test/browser/booking.mjs`, `playwright_test.go`, `Makefile`, `package.json` | browser | 7 |
62| `AGENTS.md` | §11 | 8 |
63
64---
65
66### Task 1: Models, migration, purposes, page store, ownership
67
68Spec §2.1–2.6, §7.4 (publish).
69
70**Produces:**
71
72```go
73// models_booking.go
74const (
75 BookingHeld = "held"
76 BookingConfirmed = "confirmed"
77 BookingCancelled = "cancelled"
78 BookingExpired = "expired"
79
80 CancelledByNobody = ""
81 CancelledByBooker = "booker"
82 CancelledByHost = "host"
83
84 MailHold = "hold"
85 MailConfirmed = "confirmed"
86 MailRescheduled = "rescheduled"
87 MailCancelled = "cancelled"
88 ToBooker = "booker"
89 ToHost = "host"
90
91 HoldTTL = 30 * time.Minute
92 MinNotice = 10 * time.Minute
93 MaxReschedules = 5
94 IPForgetAfter = 24 * time.Hour
95 MailGaveUp = int64(-1) // sent_at value meaning "stopped trying"
96
97 MaxPageTitleBytes = 100
98 MaxPageDescBytes = 1000
99 MaxBookerNameRunes = 60
100 MaxBookerNameBytes = 80
101 MaxNoteRunes = 1000
102 MaxNoteBytes = 4000
103)
104
105type Window struct{ Weekday time.Weekday; StartMin, EndMin int }
106
107type BookingPage struct {
108 ID int64 `gorm:"primaryKey"`
109 PersonID int64 `gorm:"column:person_id;not null;uniqueIndex:idx_booking_page_code"`
110 Code string `gorm:"column:code;not null;uniqueIndex:idx_booking_page_code"`
111 TitleSealed []byte `gorm:"column:title_sealed"`
112 DescriptionSealed []byte `gorm:"column:description_sealed"`
113 DurationMin int `gorm:"column:duration_min;not null"`
114 BufferMin int `gorm:"column:buffer_min;not null"`
115 NoticeMin int `gorm:"column:notice_min;not null"`
116 HorizonDays int `gorm:"column:horizon_days;not null"`
117 AvailabilitySealed []byte `gorm:"column:availability_text_sealed"`
118 PublishedAt *int64 `gorm:"column:published_at"`
119 CreatedAt time.Time `gorm:"column:created_at;not null"`
120 UpdatedAt time.Time `gorm:"column:updated_at;not null"`
121}
122
123type BookingWindow struct {
124 ID int64 `gorm:"primaryKey"`
125 PageID int64 `gorm:"column:page_id;not null;index"`
126 Weekday int `gorm:"column:weekday;not null"`
127 StartMin int `gorm:"column:start_min;not null"`
128 EndMin int `gorm:"column:end_min;not null"`
129}
130
131type Booking struct {
132 ID int64 `gorm:"primaryKey"`
133 PageID int64 `gorm:"column:page_id;not null;index"`
134 EventID int64 `gorm:"column:event_id;not null;index"`
135 TokenHash string `gorm:"column:token_hash;not null;uniqueIndex"`
136 TokenSealed []byte `gorm:"column:token_sealed"`
137 State string `gorm:"column:state;not null;index"`
138 BookerNameSealed []byte `gorm:"column:booker_name_sealed"`
139 BookerAddressSealed []byte `gorm:"column:booker_address_sealed"`
140 BookerAddressFP string `gorm:"column:booker_address_fp;not null;index"`
141 NoteSealed []byte `gorm:"column:note_sealed"`
142 BookerZone string `gorm:"column:booker_zone;not null"`
143 HoldExpiresAt int64 `gorm:"column:hold_expires_at;not null;index"`
144 ConfirmedAt *int64 `gorm:"column:confirmed_at"`
145 CancelledAt *int64 `gorm:"column:cancelled_at"`
146 CancelledBy string `gorm:"column:cancelled_by;not null"`
147 IPFP *string `gorm:"column:ip_fp;index"`
148 IPForgetAt int64 `gorm:"column:ip_forget_at;not null;index"`
149 Reschedules int `gorm:"column:reschedules;not null"`
150 CreatedAt time.Time `gorm:"column:created_at;not null"`
151 UpdatedAt time.Time `gorm:"column:updated_at;not null"`
152}
153
154type BookingMail struct {
155 ID int64 `gorm:"primaryKey"`
156 BookingID int64 `gorm:"column:booking_id;not null;uniqueIndex:idx_booking_mail_once"`
157 Kind string `gorm:"column:kind;not null;uniqueIndex:idx_booking_mail_once"`
158 Recipient string `gorm:"column:recipient;not null;uniqueIndex:idx_booking_mail_once"`
159 Revision int `gorm:"column:revision;not null;uniqueIndex:idx_booking_mail_once"`
160 DueAt int64 `gorm:"column:due_at;not null;index"`
161 Attempts int `gorm:"column:attempts;not null"`
162 ClaimedAt *int64 `gorm:"column:claimed_at"`
163 SentAt *int64 `gorm:"column:sent_at"`
164 CreatedAt time.Time `gorm:"column:created_at;not null"`
165}
166
167// models.go, on Person after Zone:
168Handle *string `gorm:"column:handle;uniqueIndex"`
169
170// seal/seal.go
171PurposeBookingTitle = "booking.title"
172PurposeBookingDescription = "booking.description"
173PurposeBookingAvailability = "booking.availability"
174PurposeBookingToken = "booking.token"
175PurposeBookerName = "booking.booker.name"
176PurposeBookerAddress = "booking.booker.address"
177PurposeBookerNote = "booking.booker.note"
178PurposeBookingIP = "booking.ip"
179
180// store_booking_page.go
181type BookingPageInput struct {
182 Title, Description, Availability string
183 DurationMin, BufferMin, NoticeMin, HorizonDays int
184}
185type BookingPageView struct {
186 BookingPage
187 Title, Description, Availability string
188 Windows []Window
189 Published bool
190}
191var ErrHandleTaken = errors.New("calendar: handle taken")
192
193func ValidateHandle(h string) error // ^[a-z0-9-]{2,32}$ after lowercasing; not m/new/static
194func ValidateBookingPageInput(in *BookingPageInput) error // trims; len(title) 1..100 BYTES; len(desc) <= 1000 BYTES; duration 5..480; buffer 0..120; notice 0..10080; horizon 1..90
195func newPageCode() string // 8 × [a-z0-9], crypto/rand
196func (s *Store) SaveHandle(ctx, personID int64, handle string) error
197func (s *Store) PersonByHandle(ctx, handle string) (Person, error)
198func (s *Store) CreateBookingPage(ctx, personID int64, in BookingPageInput, windows []Window) (BookingPage, error)
199func (s *Store) UpdateBookingPage(ctx, personID, pageID int64, in BookingPageInput, windows []Window) error
200func (s *Store) PublishBookingPage(ctx, personID, pageID int64, publish bool) error
201func (s *Store) BookingPages(ctx, personID int64) ([]BookingPageView, error)
202func (s *Store) BookingPage(ctx, personID, pageID int64) (BookingPageView, error)
203func (s *Store) PublicBookingPage(ctx, handle, code string) (BookingPageView, Person, error)
204
205// testing.go
206func (a *App) StoreForTest() *Store
207func (a *App) PersonByAddressForTest(t TB, address string) Person
208```
209
210- [ ] **Step 1: Failing tests** — `booking_store_test.go`: `TestABookingPageRoundTripsSealedWithItsWindows` (8-char code; unpublished → `NotFound`; publish → visible with `Published`; sealed grep for title/description/availability), `TestUnknownHandleUnknownCodeAndUnpublishedAllVanishTheSameWay`, `TestAHandleIsUniqueLowercaseAndNotReserved` (`"Paul"` saves as `paul`; second claim `ErrHandleTaken`; `m`, `new`, `static`, `a`, `has space`, `üñí` refused; `""` clears), `TestAnotherPersonCannotReadEditOrPublishMyPage` (all three `NotFound`), `TestPageLimitsAreBytes` (a 101-byte title of 34 three-byte runes is refused; a 100-byte one accepted).
211- [ ] **Step 2: Run; compile failure.**
212- [ ] **Step 3: Models, purposes, `Handle`, `Models` registration (all four new models), `testing.go` hooks.**
213- [ ] **Step 4: Generate the migration; check.** Inspect: `handle` nullable + unique; `ip_fp` nullable; `published_at` nullable; `token_sealed` blob; the four-column unique on `booking_mails`.
214- [ ] **Step 5: `store_booking_page.go`.** Every scoped call is `Where("id = ? AND person_id = ?")`; miss → `ErrNotFound`. Updates by explicit map. Windows deleted + reinserted in the same transaction. `PublicBookingPage`: `PersonByHandle` → `First(&page, "person_id = ? AND code = ? AND published_at IS NOT NULL")` → open → windows ordered.
215- [ ] **Step 6: Pass. `make ci`. Commit** `booking: pages, windows, bookings and their mail — the tables, sealed where they are content`.
216
217---
218
219### Task 2: `Zone()` made safe; availability parser; pane
220
221Spec §3, §5.1, §9.
222
223**Produces:**
224```go
225func ParseAvailability(text string) (windows []Window, problems []string)
226func MergeWindows(ws []Window) []Window
227func DescribeWindows(ws []Window) []string
228type BookingPaneView struct{ Lines, Problems []string; Empty bool }
229func bookingPaneFor(text string) BookingPaneView
230func IsCommonZone(name string) bool // zones.go
231func ZoneCacheSizeForTest() int // testing.go
232```
233
234- [ ] **Step 1: `zone_test.go`**: 100 goroutines resolve `Nowhere/TownN` and `Europe/Dublin`; run with `-race`; afterwards `ZoneCacheSizeForTest() <= len(CommonZones)+5`.
235- [ ] **Step 2: `clock.go`**: `sync.Map`, successes only. `zones.go`: `IsCommonZone`.
236- [ ] **Step 3: `avail_test.go`** — the reading table (every §3.1 example plus: `sun-tue 9-10`; `monday 9-12, monday 10-13` → one `09:00–13:00`; `monday 9-12, 12-1` → one; `monday 22:00-24:00` → `{Monday, 1320, 1440}`; `monday 0-1am`; `12:30-2:30` under `tuesdays` → `12:30–14:30`; `9pm-11` → `21:00–23:00`), the refusing table (`10pm-2`, `whenever`, leading `12-3` with no day, `25-26`, `9:60-10`, `13pm-2pm`, `24:00-1`, `9-9`, `9-5 in the office` quoting `in the office`; two bad clauses → two problems), and `DescribeWindows`.
237- [ ] **Step 4: Implement** `avail.go` + `avail_describe.go` (a `_windows.go` suffix is a GOOS build constraint; Go would skip the file on Linux):
238
239```go
240var (
241 reAvailSplit = regexp.MustCompile(`\s*[,;]\s*`)
242 reAvailGroup = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(weekdays|weekends|every\s+day|daily|all\s+days)\b`)
243 reAvailDayRange = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?` + rangeSep + `(` + weekdayAlt + `)s?\b`)
244 reAvailDay = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?\b`)
245 reAvailRange = regexp.MustCompile(`(?i)\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:[-–—]|to|until|till)\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b`)
246 reAvailFiller = regexp.MustCompile(`(?i)\b(between|from|and|on|at)\b`)
247)
248
249// readClock validates RAW components; nothing is clamped.
250// meridiem != "": hour 1..12, minute 0..59; 12am → 0, 12pm → 12.
251// meridiem == "": hour 0..24 (24 only with minute 0 and only when atEnd), minute 0..59;
252// if inferPM and 1 <= hour <= 7, hour += 12 — on the HOUR COMPONENT, whatever the minutes.
253func readClock(hh, mm, meridiem string, inferPM, atEnd bool) (minutes int, ok bool)
254```
255Range rule: `sm, em := m[3], m[6]`; if exactly one is set, the other inherits it; `inferPM := sm == "" && em == ""`. `end <= start` → problem quoting `m[0]`. Every unconsumed non-filler token → problem quoting the clause. `MergeWindows` sorts by weekday then start and merges `next.StartMin <= cur.EndMin`.
256
257- [ ] **Step 5: `_bookingpane.html`** (as in revision-2 plan) into `fragments` and every page ParseFS.
258- [ ] **Step 6: Pass. `make ci`. Commit** `booking: one parser for "mondays 12–3", and Zone() stops caching garbage`.
259
260---
261
262### Task 3: The range-query fix; slot arithmetic
263
264Spec §4, §4.1, §4.2 (day validation only; the bucket is Task 6).
265
266**Produces:**
267```go
268type Slot struct{ StartUTC, EndUTC int64; Local time.Time }
269type Busy struct{ StartUTC, EndUTC int64 }
270type SlotRule struct{ DurationMin, BufferMin, NoticeMin, HorizonDays int; Zone string; Windows []Window }
271func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot, error)
272func (s *Store) BusyFor(ctx, hostID int64, from, to int64, now time.Time, excludeEventID int64) ([]Busy, error)
273func (s *Store) FreeSlots(ctx, page BookingPageView, host Person, now, from, to time.Time, excludeEventID int64) ([]Slot, error)
274func ruleFor(page BookingPageView, host Person) SlotRule
275```
276
277- [ ] **Step 1: The range regression first** — `occurrence_test.go`, `TestAMovedOccurrenceOfAFinishedSeriesStillShows`: create a daily series with `repeat_end` (check the form's field name in `event_form.html`) three days from now; move its LAST occurrence (`scope=this`) to ten days from now; `GET /day/<that day>` must show the chip. Run: FAIL (the override is dropped).
278- [ ] **Step 2: Fix `store_range.go`**: after collecting `masterIDs` from masters, also add `*r.SeriesID` for every row with `r.SeriesID != nil` whose master is not already listed. Pass.
279- [ ] **Step 3: `slots_test.go`** (helpers `dublin`, `mon = 2026-09-07 Dublin`, `mondayRule(dur, buffer)` with 12:00–15:00 Monday, `starts()`, `eq()`):
280 - step-by-duration → `12:00, 13:00, 14:00`
281 - busy 13:00–13:30 → `12:00, 14:00`
282 - buffer 10 → `14:00`
283 - busy 11:50–11:55 with `from = 12:00`, buffer 10 → `13:00, 14:00`
284 - notice floor: `NoticeMin: 0`, now = 11:55 → 12:00 is NOT offered (`MinNotice` is 10 min)
285 - notice 120, now 11:00, horizon 7, `to` = +14d → `Mon 13:00, Mon 14:00` only
286 - `to` bounds start: rule window Monday 23:00–24:00, 60-min, `to` = Tuesday 00:00 local → the 23:00 slot IS offered
287 - spring-forward 2026-03-29 with 00:00–04:00 / 60: no slot straddles 01:00; every offered start and end satisfies `ToUTC(FormatLocal(x)) == x.Unix()`; pin the list once observed
288 - fold 2026-10-25 with 00:00–04:00 / 60: same assertions; additionally assert `(EndUTC-StartUTC) == 3600` for every slot
289 - Tokyo host Monday 09:00 → LA Sunday
290- [ ] **Step 4: Implement `slots.go`:**
291
292```go
293for m := w.StartMin; m+rule.DurationMin <= w.EndMin; m += rule.DurationMin {
294 start := time.Date(day.Year(), day.Month(), day.Day(), 0, m, 0, 0, loc)
295 end := start.Add(dur)
296 if !wallIs(start, day, m) || !wallIs(end, day, m+rule.DurationMin) { continue }
297 if !roundTrips(start, rule.Zone) || !roundTrips(end, rule.Zone) { continue }
298 if start.Before(earliest) || start.Before(from) || !start.Before(to) || end.After(latest) { continue }
299 if overlapsAny(start.Add(-pad).Unix(), end.Add(pad).Unix(), busy) { continue }
300 out = append(out, Slot{start.Unix(), end.Unix(), start})
301}
302// wallIs: t is on `day`'s local date (or, when minutes == 1440, the next date at 00:00) and t.Hour()*60+t.Minute() == minutes%1440.
303// roundTrips: u, err := ToUTC(FormatLocal(t, false), zone, false); err == nil && u == t.Unix().
304// earliest := now.Add(max(MinNotice, NoticeMin)); latest := now.In(loc).AddDate(0,0,HorizonDays).
305```
306`store_slots.go`: envelope ±1 day; for all-day, `evLoc := zoneOrUTC(e.TZ)`; `first := time.Unix(e.OccStartUTC,0).In(evLoc)`; `last := time.Unix(e.OccEndUTC-1,0).In(evLoc)`; for each date in `[first, last]` add `DayBounds(date, host.Zone)`. Lapsed holds: `SELECT event_id FROM bookings WHERE state='held' AND hold_expires_at <= ?` → skip set.
307
308- [ ] **Step 5: Pass; `make ci`; commit** `booking: free slots — the host's windows minus everything the host can see, and the range query stops losing moved occurrences`.
309
310---
311
312### Task 4: The state machine, the mail queue, the sweep queries, the host's authority
313
314Spec §5.2–5.7 (store), §2.5, §6 (queue), §7.1 (counts), §8 (queries), §5.5 (host authority).
315
316**Produces:**
317
318```go
319// store.go
320type Store struct { g *gorm.DB; seal *seal.Sealer; logger *slog.Logger; holdMu sync.Mutex }
321
322// store_booking_flow.go
323var (
324 ErrSlotTaken = errors.New("calendar: slot taken")
325 ErrOverCap = errors.New("calendar: too many open holds")
326 ErrBookedEvent = ValidationError{"repeat", "This is a booked meeting; it cannot repeat."}
327)
328type HoldInput struct {
329 Page BookingPageView
330 Host Person
331 StartUTC, EndUTC int64
332 Name, Address string
333 Note, BookerZone string
334 IPFP string
335}
336func (s *Store) HoldBooking(ctx, in HoldInput, now time.Time) (Booking, error) // holdMu; caps; slot; tx incl. mail row; token returned via view
337func (s *Store) ConfirmBooking(ctx, bookingID int64, now time.Time) (changed bool, err error) // guard: held, expires > now, now < event.start
338func (s *Store) RescheduleBooking(ctx, bookingID, startUTC, endUTC int64, rev int, now time.Time) (changed bool, err error) // holdMu; ErrOverCap; ErrSlotTaken; rev mismatch → changed=false
339func (s *Store) CancelBooking(ctx, bookingID int64, by string, now time.Time) (changed bool, err error)
340func (s *Store) cancelBookingsForEvent(tx *gorm.DB, eventID int64, by string, now time.Time) error // used by UpdateEvent/DeleteEvent
341
342// store_booking_read.go
343type BookingView struct {
344 Booking
345 Page BookingPageView
346 Host Person
347 HostName string
348 Event Event // the ROW, for Status/StartUTC/EndUTC/StartLocal/EndLocal/TZ/Sequence/DeletedAt only
349 EventUID string
350 Name, Address, Note string
351 Token string // opened; empty unless withToken
352}
353type HoldCounts struct{ OpenIP, OpenPage, OpenAddress, AttemptsIP, AttemptsAddress int }
354func (s *Store) BookingByToken(ctx, token string) (BookingView, error)
355func (s *Store) BookingByID(ctx, id int64, withToken bool) (BookingView, error)
356func (s *Store) HoldCountsFor(ctx, pageID int64, ipFP, addressFP string, now time.Time) (HoldCounts, error)
357func (s *Store) bookingIDForEvent(ctx, eventID int64) (int64, bool)
358
359// store_booking_mail.go
360func queueBookingMail(tx *gorm.DB, bookingID int64, kind string, recipients []string, revision int, now time.Time) error
361func (s *Store) ClaimBookingMail(ctx, now int64) (BookingMail, bool, error) // lease = claimLease; oldest due first
362func (s *Store) MarkBookingMailSent(ctx, id int64, now int64) error // WHERE id=? AND sent_at IS NULL
363func (s *Store) MarkBookingMailFailed(ctx, id int64, now int64) error // attempts+1; at MaxReminderAttempts sent_at = MailGaveUp
364func (s *Store) NextBookingMailDue(ctx) time.Time
365func (s *Store) UnsentBookingMailForTest(bookingID int64) []BookingMail
366
367// store_booking_sweep.go
368func (s *Store) ExpireHolds(ctx, now time.Time, limit int) (int, error)
369func (s *Store) ForgetOldIPs(ctx, now time.Time, limit int) (int, error)
370func (s *Store) NextHoldExpiry(ctx) time.Time
371func (s *Store) NextIPForget(ctx) time.Time
372```
373
374- [ ] **Step 1: Tests** — `booking_flow_test.go` (helpers `hostWithPage`, `slotsFor`) and `booking_mail_test.go`:
375 - hold: tentative event titled `"Chat — Doc"`; `HoldExpiresAt == min(now+30m, start)` — test both branches with a slot 5 minutes out (notice 0 but `MinNotice` 10 → use 15 minutes out: expiry = start); one unsent `MailHold/ToBooker` row; token not on disk; `BookingByToken(token)` opens the same row and `BookingByID(id, true).Token == token`.
376 - 20 goroutines, one slot, one winner (`-race`).
377 - confirm: first `changed`; second not; event confirmed `Sequence == 1`; attendee; default reminder when host has one; one `MailConfirmed/ToHost` row at revision 1.
378 - confirm after `hold_expires_at` → no change; confirm when `now >= event.StartUTC` → no change (age the event via `DBForTest`).
379 - reschedule with correct `rev` → `changed`, sequence 2, `Reschedules 1`, two `MailRescheduled` rows at rev 2; the same call again with the OLD rev → `changed=false`, nothing else moved; back to the original slot works; sixth → `ErrOverCap`.
380 - cancel by booker: `changed`; event cancelled, sequence bumped; `ReminderQueue` rows gone; unsent `MailRescheduled` rows deleted; two `MailCancelled` rows; `CancelledBy == booker`; second → no change.
381 - host `UpdateEvent` with `Status: cancelled` → booking cancelled by host, one `MailCancelled/ToBooker`; host `DeleteEvent` → same plus sequence bumped on the deleted row; a `held` booking on a deleted event → `expired`.
382 - `UpdateEvent` with `RRule` on a booked event → `ErrBookedEvent`.
383 - mail queue: `ClaimBookingMail` twice in a row returns the same row only after `claimLease`; `MarkBookingMailSent` is conditional; `MarkBookingMailFailed` × `MaxReminderAttempts` → `SentAt == MailGaveUp`; `NextBookingMailDue` is zero when nothing is unsent.
384 - sweep: `ExpireHolds` → expired, event soft-deleted, queue rows gone, slot free; `NextHoldExpiry`; `ForgetOldIPs` at `ip_forget_at`; `NextIPForget`.
385 - `HoldCountsFor`: three holds one IP → `OpenIP 3, AttemptsIP 3`; expire → `OpenIP 0, AttemptsIP 3`.
386 - `NextDueAt` returns each of the four sources when it is earliest.
387 - reminder claim path: a queue row whose event is `cancelled` is dropped (`ClaimDueReminder` returns `ok=false` and deletes it).
388- [ ] **Step 2: Run; failure.**
389- [ ] **Step 3: Implement.** Sketches:
390
391```go
392func (s *Store) HoldBooking(ctx context.Context, in HoldInput, now time.Time) (Booking, error) {
393 s.holdMu.Lock()
394 defer s.holdMu.Unlock()
395 addrFP, _ := s.seal.Fingerprint(seal.PurposeAttendeeLookup, normalizeAddress(in.Address))
396 c, err := s.HoldCountsFor(ctx, in.Page.ID, in.IPFP, addrFP, now)
397 if err != nil { return Booking{}, err }
398 if c.OpenIP >= 5 || c.OpenPage >= 20 || c.OpenAddress >= 2 || c.AttemptsIP >= 20 || c.AttemptsAddress >= 6 { return Booking{}, ErrOverCap }
399 loc, _ := Zone(in.Host.Zone)
400 d := time.Unix(in.StartUTC, 0).In(loc)
401 day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
402 slots, err := s.FreeSlots(ctx, in.Page, in.Host, now, day, day.AddDate(0, 0, 1), 0)
403 if err != nil { return Booking{}, err }
404 if !hasStart(slots, in.StartUTC) { return Booking{}, ErrSlotTaken }
405 token := newToken() // 32 bytes hex, as MintFeed
406 var b Booking
407 err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
408 calID, err := s.defaultCalendarIDTx(tx, in.Host.ID)
409 e, err := s.buildEvent(in.Host.ID, EventInput{CalendarID: calID, Title: in.Page.Title + " — " + in.Name, Notes: in.Note,
410 StartLocal: FormatLocal(time.Unix(in.StartUTC,0).In(loc), false), EndLocal: FormatLocal(time.Unix(in.EndUTC,0).In(loc), false),
411 TZ: in.Host.Zone, Kind: KindMeeting, Status: StatusTentative})
412 if err := tx.Create(&e).Error; err != nil { return err }
413 expires := now.Add(HoldTTL).Unix(); if in.StartUTC < expires { expires = in.StartUTC }
414 b = Booking{PageID: in.Page.ID, EventID: e.ID, TokenHash: HashFeedToken(token), TokenSealed: seal(token), State: BookingHeld, ..., HoldExpiresAt: expires, IPFP: &in.IPFP, IPForgetAt: now.Add(IPForgetAfter).Unix(), ...}
415 if err := tx.Create(&b).Error; err != nil { return err }
416 return queueBookingMail(tx, b.ID, MailHold, []string{ToBooker}, 0, now)
417 })
418 return b, err
419}
420```
421`ConfirmBooking`: in one transaction read the event row (`start_utc`), then `UPDATE bookings SET state='confirmed', confirmed_at=?, updated_at=? WHERE id=? AND state='held' AND hold_expires_at > ? AND ? < ?` (now, start); `RowsAffected == 0 → return false, nil`; `UPDATE events SET status='confirmed', sequence = sequence + 1, updated_at=? WHERE id=?`; re-read `sequence`; `SetAttendees` (make a `tx` variant or call after commit — say which); `queueBookingMail(tx, id, MailConfirmed, [ToHost], seq, now)`. After commit: `EnsureDefaultReminder(ctx, host, event)`.
422
423`RescheduleBooking`: `holdMu`; read view; `State != confirmed → false,nil`; `Reschedules >= MaxReschedules → ErrOverCap`; `FreeSlots(..., excludeEventID)` with `dur := Event.EndUTC - Event.StartUTC` — the rule's `DurationMin` for this call is `dur/60`; slot present else `ErrSlotTaken`; tx: `UPDATE events SET start_local, end_local, start_utc, end_utc, sequence = sequence + 1, updated_at WHERE id = ? AND sequence = ?` (rev) → 0 rows → `false, nil`; `UPDATE bookings SET reschedules = reschedules + 1, updated_at WHERE id = ? AND state = 'confirmed'`; `queueBookingMail(... MailRescheduled, [ToBooker, ToHost], rev+1 ...)`; after commit: `RescheduleReminder` for each `RemindersFor(host, eventID)`.
424
425`CancelBooking(by)`: tx: `UPDATE bookings SET state='cancelled', cancelled_at, cancelled_by=?, updated_at WHERE id=? AND state='confirmed'` → 0 → `false,nil`; `UPDATE events SET status='cancelled', sequence = sequence + 1`; `DELETE FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`; `DELETE FROM booking_mails WHERE booking_id = ? AND kind = 'rescheduled' AND sent_at IS NULL`; queue `MailCancelled` to both (or booker only when `by == host`).
426
427`cancelBookingsForEvent(tx, eventID, by, now)`: the same as the confirmed branch for `state='confirmed'` bookings, and `UPDATE bookings SET state='expired' WHERE event_id=? AND state='held'`. Called from `UpdateEvent` when `in.Status == StatusCancelled && existing.Status != StatusCancelled`, and from `DeleteEvent` (which also does `sequence = sequence + 1`). `UpdateEvent`: `if in.RRule != "" { if _, booked := s.bookingIDForEvent(ctx, eventID); booked { return ErrBookedEvent } }`; change `"sequence": existing.Sequence + 1` to `gorm.Expr("sequence + 1")`.
428
429`store_drain.go` `ClaimDueReminder`: after loading `e`, `if e.Status == StatusCancelled { delete queue row; return DueReminder{}, false, nil }`.
430
431`NextDueAt`: min over four non-zero times.
432
433- [ ] **Step 4: `go test -race ./internal/calendartest/ -run 'Hold|Confirm|Reschedule|Cancel|Booked|Expire|Mail|Forget|NextDue|Claim' -count=1`; pass. `make ci`; commit** `booking: held → confirmed → cancelled as conditional updates, with mail as a queue the drain owns`.
434
435---
436
437### Task 5: Host settings UI
438
439Spec §2.1, §3.4, §7.4. Unchanged from revision 2 except: the publish button and its sentence (§7.4); byte limits on title/description in the form's server-side validation message ("Keep it under 100 bytes" is unfriendly — say "Keep it shorter" and rely on the validator); the list shows `Published`/`Not published`.
440
441**Routes (signed-in group):** `GET /settings/booking`, `GET /settings/booking/new`, `POST /settings/booking`, `GET /settings/booking/{id}`, `POST /settings/booking/{id}`, `POST /settings/booking/{id}/publish` (`publish=1|0`), `POST /settings/booking/parse`.
442
443- [ ] **Step 1: Tests** — `booking_settings_test.go`: page made and listed as not published with no URL; publish → URL `/book/paul/<8 chars>` and the §7.4 sentence present; unreadable sentence → 422 quoting it, nothing saved; pane fragment; another person → 404 on GET/POST/publish; taken handle → 422 "taken".
444- [ ] **Step 2–3: Implement** as in the revision-2 plan (handlers, `bookingPageForm`, templates, `booking.js`, CSS). `settings.html` gains the `handle` field and a link to `/settings/booking`. `booking.js` also does the public page's zone preselect (`[cal-book-zone]` without `data-chosen`).
445- [ ] **Step 4: Pass; `make ci`; commit** `booking: the host's side — a handle, pages, a pane, and a Publish button that says what it does`.
446
447---
448
449### Task 6: The public flow, mail, ICS, the timed sender, the token bucket
450
451Spec §4.2, §5, §6, §7.1–7.2, §2.7.
452
453**Routes (chi router, outside the session group, `/book/m/...` registered BEFORE `/book/{handle}/{code}`):**
454```
455GET /book/m/{token} held: confirm button; confirmed: manage; else: gone
456POST /book/m/{token}/confirm
457GET /book/m/{token}/calendar.ics
458GET /book/m/{token}/reschedule ?day=&zone= (+ hidden rev)
459POST /book/m/{token}/reschedule start, zone, rev
460POST /book/m/{token}/cancel
461GET /book/{handle}/{code} ?day=&zone=
462POST /book/{handle}/{code} start, zone [, name, address, note]
463```
464
465**Produces:**
466```go
467// handlers_booking_util.go
468func clientIP(r *http.Request) string
469func (h *handlers) publicGate(next http.HandlerFunc) http.HandlerFunc // token bucket 60/min per ip_fp → 429; sets no-store + no-referrer; wraps every public route
470func notFoundLikeTheRouter(w, r)
471func zoneChoices(host Person) []string // CommonZones ∪ {host.Zone}
472func pickZone(r *http.Request, host Person) (zone string, chosen bool)
473type bucket struct{ mu sync.Mutex; tokens map[string]*tb } // 60 tokens, refill 1/s, prune idle keys past 5 min
474
475// mail_timed.go
476var ErrMailTimeout = errors.New("calendar: mail send timed out")
477type timedSender struct{ next mail.Sender; limit time.Duration }
478func (t timedSender) Send(ctx, to, subject, body string) error // goroutine + select on time.After(limit); the request WAITS here
479
480// booking_mail.go
481func (h *handlers) sendBookingMail(ctx context.Context, m BookingMail) error // loads BookingByID(withToken=true), renders by kind+recipient, sends via h.mailer (already wrapped), marks sent/failed
482func (h *handlers) trySendNow(ctx context.Context, bookingID int64) // claims and sends every unsent row for the booking, once; errors only logged (scrubbed)
483
484// booking_ics.go
485func WriteBookingICS(v BookingView) string // METHOD on VCALENDAR; one VEVENT; allowlisted fields only; no URL:
486```
487
488`New` wraps the configured mailer: `h.mailer = timedSender{next: mailer, limit: 20 * time.Second}` (and the recorder stays outermost so tests still see bodies — check the order in `buildMailer`).
489
490- [ ] **Step 1: Tests** — `booking_http_test.go` (helpers: `publishedPage(t)` returning `app, mailer, path, host`; `strangerGet/strangerPost` with optional `RemoteAddr`; `reStart`, `reManage`, `reRev = regexp.MustCompile(`name="rev" value="(\d+)"`)`; extend `pickyMailer` with `to` matching and a `delivered []struct{To, Body string}`):
491 - two-step hold; one mail to booker; GET link → button, state still held, still one mail; POST confirm → manage, host mailed once; second POST → nothing.
492 - confirm after `event.start_utc` (age via SQL) → page says expired/too late, no change.
493 - ICS: `METHOD:REQUEST` on the VCALENDAR line; `SUMMARY:Quick chat`; `ATTENDEE;CN=Doc;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:doc@tito.io`; `ORGANIZER`; `mailto:host@example.com`; `SEQUENCE:1`; no `URL:`; `ParseICS(strings.NewReader(body), "UTC")` yields one event.
494 - host edits never reach the booker (note, location, second attendee → absent from manage, ICS, cancelled mail).
495 - same slot twice → 409; wrong token → byte-identical 404; expired says expired.
496 - reschedule: GET has `rev`; POST with it → 2 mails, `SEQUENCE:2`; the same POST again → 200, no change, no mail; cancel → `METHOD:CANCEL`, 2 mails; again → nothing.
497 - host cancels via edit → booker mailed, page says cancelled by host; host deletes → same, ICS sequence higher.
498 - hold mail refused (`refuse = "bounce@example.com"`): 200 "Check your mail" with the lapse sentence; the slot is STILL held (a second stranger gets 409); the mail row is unsent with `attempts 1`.
499 - `no-store` and `no-referrer` on every public response.
500 `booking_abuse_test.go`: sixth open hold per IP 429; third per address 429; 21st attempt in 24h 429 with none open (use refused mails so none confirm; they still count); body > 16 KiB → 422; `?zone=Nowhere/Town` falls back to host zone; `?day=` past horizon → 404; 61 GETs in a minute from one IP → the 61st is 429 (use a test hook `ResetPublicGateForTest`).
501 `timedSender`: a `stallingMailer` that blocks on a channel → `Send` returns `ErrMailTimeout` within `limit+1s` (set `limit` small via a constructor).
502- [ ] **Step 2: Run; failure.**
503- [ ] **Step 3: Implement** per spec §5. Hold response: always 200 `book_sent`; when `trySendNow` reported an error for the hold row, `Data.Unsure = true` adds the lapse sentence. Manage page template receives only allowlisted fields (build a `publicBooking` struct with exactly: `Title, Description, StartUTC, EndUTC, Status, HostName, HostAddress, Name, Address, CancelledBy, Zone, HostZone, Rev` — never pass `BookingView` to a public template).
504- [ ] **Step 4: Pass; `make ci`; commit** `booking: a stranger holds a slot, a button confirms it, and the ICS is one click away`.
505
506---
507
508### Task 7: Drain ordering, leak and log tests, browser passes
509
510Spec §8, §9.
511
512- [ ] **Step 1: Tests** — `booking_sweep_test.go`: `tick` expires and frees; `tick` sends owed mail (refuse then un-refuse); reminders still progress with 30 unsent booking-mail rows queued (`drainDeadline` halves — fake by making the mailer slow: a `slowMailer` sleeping 100 ms; assert a due reminder was sent in the same tick); `DrainOnRequest` does exactly one thing (one reminder OR one mail OR one expiry per GET); `ip_fp` nulled at `ip_forget_at`; `NextDue` reflects each source. Log test with a buffer logger: no address, no 64-hex token. Leak fixture in `TestTheDatabaseNeverHoldsAPlaintextTitle` (seven needles including the token).
513- [ ] **Step 2: Implement** `drain`: `ExpireHolds(200)` → reminder loop until `now+deadline/2` → booking mail claim loop until `deadline` → `ForgetOldIPs(500)`. `DrainOnRequest`: reminder if claimable, else one booking mail, else one expiry.
514- [ ] **Step 3: Browser** — `test/browser/booking.mjs` with subcommands `hold <origin> <path>` and `confirm <origin> <link>` (JS off, no cookie) and `pane <origin> <cookieName> <cookieValue>` (JS on). `TestBookingInARealBrowser` in `playwright_test.go` creates host/handle/page/publish via the store, runs `hold`, reads `LastMailForTest`, extracts the link, runs `confirm`, then `pane`. `Makefile`/`package.json` → `-run InARealBrowser`.
515- [ ] **Step 4: `make ci` and `make browser` green; commit** `booking: holds expire on the tick, owed mail is retried, reminders keep their half, and a stranger with no script can book and confirm`.
516
517---
518
519### Task 8: AGENTS.md
520
521Spec §11, verbatim: rows B1, B2, B4, B5, B8, B11–B17; the Frontend, two Data, Ticks (rename + two bullets), two Time bullets; leak-list item 5. Commit `docs: the rules a booking page adds`.
522
523---
524
525## Self-review
526
527- **Spec coverage:** §2 → T1 (all columns, byte limits, `booking_mails`); §2.6 → T1 scoping + T4 terms; §2.7 → T6 `clientIP`; §3 → T2, T5; §4 → T3 (four-part check, `to` bounds start, notice floor, all-day via Occ, lapsed holds, ±1 day); §4.1 → T3 step 1–2; §4.2 → T3 (`day` validation lives in T6's handler; the one-query rule in T6) + T6 bucket; §5.1–5.2 → T6; §5.3–5.4 → T6 + T4 guards; §5.5 → T4 (host authority) + T6 (allowlist struct); §5.6–5.7 → T4, T6; §6 → T4 queue + T6 render/timed + T7 retry; §7 → T4 counts, T6 429s + bucket, T7 forget; §8 → T7; §9 → each Step 1; §11 → T8.
528- **Type consistency:** `Window` once; `BookingView.Event` is the `Event` ROW (not `EventView`) so public code cannot reach opened notes by accident — the manage/ICS code builds `publicBooking` from allowlisted fields; `HoldInput.Page BookingPageView`; `MailHold…`, `ToBooker/ToHost`, `CancelledBy*` constants match the spec's column values; `RescheduleBooking` takes `rev int` and returns `changed`.
529- **Placeholders:** none. DST expectations are pinned by observing once and recording, the repository's convention.
530
531
532---
533
534## Revision 4–6 addenda (binding over the tasks above)
535
536Five review rounds after the plan was first written. Each item names the spec section that now governs. Where a task's text above disagrees with an addendum, the addendum wins; where an addendum disagrees with the spec, the spec wins.
537
538### Landed state
539
540- Task 1 is on the branch (`6f76ba4`): `models_booking.go` has `Booking.TitleSealed`, `Booking.DescriptionSealed`, `BookingMail.ClaimID`, and `DefaultBufferMin/DefaultNoticeMin/DefaultHorizonDays`. The store does NOT merge windows — callers pass `MergeWindows(ParseAvailability(...))` output. `PersonByAddressForTest` runs `EnsurePersonSetup`. `ValidateBookingPageInput` does not default zero knobs.
541- `Window` lives in `slots.go`.
542
543### Task 3b — the remaining calendar fixes (spec §4.1, B23)
544
545Do these in `occurrences.go` / `store_range.go` with regressions in `occurrence_test.go` BEFORE Task 4 depends on `EventsInRangeStrict`:
5461. `daysBetween` → difference of CIVIL dates: build both ends as `time.Date(y, m, d, 0,0,0,0, time.UTC)` and divide by 24h. Test: weekly Dublin event 2026-03-28 09:00 → 2026-03-30 09:00 still occupies 30 March on its first and on a later occurrence.
5472. `rangeRows` master exclusion: `events.until_utc + (events.end_utc - events.start_utc) + 90000 >= ?`. Test: `UNTIL` the night before the fold, final occurrence spanning the fold day, day query on that day shows it.
5483. `ExpandComplete(...) ([]Occurrence, bool)` — the existing body, returning `false` when the 100,000 guard fired; `Expand` calls it and drops the flag. `EventsInRangeStrict(ctx, personID, from, to)` — same as `EventsInRange` but returns `ErrIncompleteExpansion` (a package var) if any master's expansion was incomplete. Test: a daily rule with `start_local` in 1700 (insert via `DBForTest`) → strict errs for a 2026 window, tolerant answers.
549
550### Task 4 addenda (spec §2.4, §2.5, §5.4–5.7, §6, §8, B18, B22, B24)
551
552- **`BusyFor`** is `func (s *Store) BusyFor(ctx, host Person, from, to int64, skip map[int64]bool) ([]Busy, error)` (Task 3 agent's signature) and MUST use `EventsInRangeStrict`. `FreeSlots(ctx, page, host, now, from, to, excludeEventID)` builds `skip` = {excludeEventID} ∪ {event_id : bookings.state='held' AND hold_expires_at <= now} and calls `ComputeSlots(ruleFor(page, host), busy, now, from, to)`; a `DurationMin` override is needed for reschedule (the EVENT's duration), so give `FreeSlots` a `durationMin int` parameter (0 = the page's).
553- **Hold** writes `TitleSealed`/`DescriptionSealed` on the booking from the page (B20). The event title is `page.Title + " — " + name`.
554- **Reminder queue identity (B22):** additive migration adds `claim_id TEXT` (nullable) and `retry_at INTEGER` (nullable) to `reminder_queues` (`ReminderQueue.ClaimID *string`, `RetryAt *int64`; regenerate). `ClaimDueReminder`: eligibility `fired_at IS NULL AND due_at <= ? AND (retry_at IS NULL OR retry_at <= ?) AND (claimed_at IS NULL OR claimed_at <= ?)` with `<=`; the claim UPDATE sets `claimed_at` AND a fresh `claim_id` (16 random bytes hex) and the same predicate; `DueReminder` carries `ClaimID`. `MarkReminderSent(ctx, queueID, claimID, now)`, `FailReminder(ctx, queueID, claimID, occStart)`, new `DeferReminder(ctx, queueID, claimID, now)` (`retry_at = now+30s`, `claimed_at = NULL`, `claim_id = NULL`, attempts untouched), and `RescheduleAfterFiring(ctx, reminderID, queueID, claimID, firedStart)` are ALL `WHERE id = ? AND claim_id = ?` and check `RowsAffected`; mark + advance run in ONE transaction. `rescheduleAfter` (any path that rewrites a queue row) clears `claim_id`, `claimed_at`, `retry_at`. Never write `due_at` from a retry. `NextDueAt` reminders term: `MIN(MAX(due_at, COALESCE(retry_at,0), COALESCE(claimed_at + 300, 0)))` over `fired_at IS NULL`.
555- **Reschedule (B24):** inside the transaction, after the event UPDATE, for each `Reminder` on the event: recompute the queue row from the NEW start (a `tx` variant of `rescheduleAfter` — factor the body to take `*gorm.DB`), clearing `claim_id/claimed_at/retry_at`. Nothing reminder-related runs after commit.
556- **Booking mail claim:** `ClaimBookingMail` sets `claimed_at = now, claim_id = <random>, due_at = now + claimLease WHERE id = ? AND sent_at IS NULL AND due_at <= now` and returns the row with its `ClaimID`. `MarkBookingMailSent(ctx, id, claimID, now)`: `WHERE id=? AND claim_id=?`. `MarkBookingMailFailed(ctx, id, claimID)`: `attempts+1`, same predicate; at `MaxReminderAttempts` also `sent_at = MailGaveUp`. NEW `DeferBookingMail(ctx, id, claimID, now)`: `due_at = now+30s, claimed_at = NULL, claim_id = ''`, attempts untouched. `NextBookingMailDue` = `MIN(due_at) WHERE sent_at IS NULL`.
557- **Supersession (B19):** cancel and expire delete ALL unsent `booking_mails` for the booking (not just `rescheduled`); reschedule deletes unsent `rescheduled` rows before queueing.
558- **Host authority (B18):** `UpdateEvent` on a booked event (`bookingIDForEvent`): refuse if submitted `StartLocal`, `EndLocal`, `TZ`, `AllDay`, `Kind` or `RRule` differ from the stored row → `ValidationError{"start", "This is a booked meeting. Its time is changed by the person who booked it, or by cancelling and letting them rebook."}`; refuse `Status` other than confirmed/cancelled; refuse `Status == confirmed` when the booking is cancelled/expired → `ValidationError{"status", "This booking was cancelled. Make a new event instead."}`; refuse any update to a soft-deleted event. Replace `ErrBookedEvent` with these. `Status → cancelled` on a confirmed booking → `cancelBookingsForEvent(tx, eventID, CancelledByHost, now)`.
559- **`cancelBookingsForEvent`** also deletes ALL unsent mail rows for the booking and queues `(cancelled, booker, seq)` only (the host did it).
560- **`ExpireHolds`** deletes unsent mail rows for the expired booking too.
561- Tests to ADD to Task 4's list: everything in spec §9 under Reminders, Mail queue (claim identity, defer keeps attempts 0, crash-after-claim leaves NextDueAt at lease end), Zone change, and the host-edit refusals (each of the six fields, reviving, deleted event, title-only succeeds).
562
563### Task 5 addenda (spec §5.5 B25, §7.4)
564
565- `templates/event_form.html` + `readEventForm`: when the event is booked (`bookingIDForEvent`), render `start`, `end`, `all_day`, `kind`, `repeat` controls `disabled` with the B18 sentence beside them, AND emit hidden inputs `start`, `end`, `tz` (the EVENT's `TZ`), `all_day`, `kind`, `repeat` carrying the stored values. `readEventForm` uses `r.FormValue("tz")` for `TZ` when non-empty (only the booked form emits it), else `p.Zone`. Test: scriptless title-only edit succeeds, and still succeeds after the host changes their profile zone.
566- The event page shows a booking badge ("Booked by {name}") and, when the booking is confirmed, the sentence "To move this meeting, cancel it and let {name} rebook."
567- Callers pass `MergeWindows(...)` output to the store (Task 1 does not merge).
568- Windows: the form's availability field is optional; an empty page offers nothing.
569
570### Task 6 addenda (spec §4.2, §5.1–5.8, §6, §7.2)
571
572- **Public data:** templates receive `publicBooking` (spec §5.5) built from `BookingView`: `Title`/`Description` from the BOOKING's snapshot (`TitleSealed`/`DescriptionSealed`), never the page's or the event's.
573- **Semaphore (§4.2):** `var slotSem = make(chan struct{}, 4)`; public handlers `select { case slotSem <- struct{}{}: defer release; default: 503 + Retry-After: 5 }` around `FreeSlots`. `ErrIncompleteExpansion` from `FreeSlots` → 503 + `Retry-After: 60`.
574- **Gate (§7.2):** bucket capped at 10,000 keys, evict idle > 5 min on insert; full with no idle key → 429 + `Retry-After: 60` for a NEW key.
575- **`boundedSender`** replaces `timedSender`: semaphore of 4 in flight (`ErrMailBusy` immediately when full), wait `min(20s, ctx deadline remaining)` → `ErrMailTimeout`. Wrap the configured sender in `New` so REMINDERS use it too (the recorder stays outermost). `sendBookingMail`: `ErrMailBusy` → `DeferBookingMail`; other error → `MarkBookingMailFailed`; nil → `MarkBookingMailSent`. `sendReminder` callers: `ErrMailBusy` → `DeferReminder`.
576- **Hold response:** always 200 `book_sent`; if the immediate try errored, add the lapse sentence. NEVER `FailHold` — that function does not exist.
577- **ICS (§5.8):** no `CN=` on ORGANIZER/ATTENDEE; `METHOD` from effective state (booking cancelled OR event cancelled OR event deleted → CANCEL); `held`/`expired` → 404; event row read including soft-deleted. `SUMMARY` = snapshot title.
578- **Confirm guard** includes `now < events.start_utc` (read inside the transaction).
579- **Reschedule POST** carries hidden `rev`; `RescheduleBooking(..., rev, ...)` writes `tz = host.Zone` with the four time columns (B21) and repairs reminder rows in the transaction (B24).
580- Mail templates say the CURRENT state; the `confirmed`-to-host template opens with "This booking has since been {state}." when the booking is no longer confirmed at render time.
581
582### Task 7 addenda (spec §8)
583
584- `drain` order: `ExpireHolds(200)` → `ForgetOldIPs(500)` → reminders until `deadline/2` → booking mail until `deadline` (each send bounded by remaining budget via ctx). `DrainOnRequest`: one expiry if due, else one reminder, else one booking mail; 5-second ctx.
585- Tests: reminders still progress with 30 queued mail rows behind a slow sender; `DrainOnRequest` does exactly one thing; `NextDueAt` reflects `retry_at` and the lease for reminders.
586
587### Task 8 addenda
588
589AGENTS.md changes are spec §11 as of revision 6 (rows B1, B2, B4, B5, B8, B11–B25; the Frontend, Data ×3, Recurrence ×2, Ticks ×3, Time ×2 bullets; leak-list item 5); plus the calendar spec's §7 debt line about `mail.SMTP` and its context.
590