oficina / calendar Public

booking: spec revision 4 after the third review, and the plan against revision 3

Eleven new findings, eleven accepted, two of them scoped rather than
solved and said so: rastrillo's SMTP sender ignores its context, so a
wrapper can bound how long a request WAITS and how many sends are in
flight, but not the socket — that is upstream debt, recorded. The rest
were real: a zero-length host edit would spin the slot loop inside the
mutex; a reschedule after the host changed zone would write Tokyo
strings on a Dublin record; a stale host form could revive a cancelled
booking; the UNTIL exclusion in the range query is wrong for a long
final occurrence — a second calendar bug this spec now owns.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 84fad399356bf08011b5c7f64e7750d6f899d9f3 parent 13b43a4
2 files changed, +724 −858
  • docs/superpowers/plans/2026-09-07-booking-plan.md +285 −510
  • docs/superpowers/specs/2026-09-07-booking-design.md +439 −348
diff --git a/docs/superpowers/plans/2026-09-07-booking-plan.md b/docs/superpowers/plans/2026-09-07-booking-plan.md
index 9715804..f2520b4 100644
--- a/docs/superpowers/plans/2026-09-07-booking-plan.md
+++ b/docs/superpowers/plans/2026-09-07-booking-plan.md
@@ -4,32 +4,33 @@
**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.
-**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 token; the existing range query is what makes a hold block a slot. Hold creation and rescheduling serialise on one process-wide mutex. Every state change is a conditional UPDATE that checks `RowsAffected`. Mail is an outbox column the existing drain retries. Public routes live beside `/feed/{token}.ics` — inside CSRF, outside the session group.
+**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.
**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.
-**Spec:** `docs/superpowers/specs/2026-09-07-booking-design.md` **revision 2**. Read it first, in full, including §12. 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.
+**Spec:** `docs/superpowers/specs/2026-09-07-booking-design.md` **revision 3**. Read it first, in full, including §12. 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.
## Global Constraints
Copied from AGENTS.md and the spec. Every task inherits them.
-- **The one rule.** Booker name, address, note, 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`, the page `code` and `ip_fp` are plaintext. Tokens are stored as `HashFeedToken(token)` only.
+- **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.
- **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.
-- **A row that isn't yours 404s, never 403s.** Unknown handle, code or token → the same 404 the router gives an unknown route (`http.NotFound` is NOT it — see Task 6 for `notFoundLikeTheRouter`).
+- **A row that isn't yours 404s, never 403s.** Unknown handle, code or token → the same body the router gives an unknown route.
- **Never bind a request body onto a model.** Explicit `Input` structs; explicit column allowlists in every `Updates`.
-- **Every booking transition is a conditional UPDATE on `state` that checks `RowsAffected == 1`.** Zero rows is "someone moved it first" — re-read and render, never retry.
-- **Hold and reschedule run inside `Store.holdMu`** (a `sync.Mutex` on `Store`), with the slot check inside the lock.
-- **Never `sessions.UserID`.** Use `CurrentPerson(r)`.
-- **Migrations are generated**: delete `internal/calendar/migrations/schema.sql`, add the model to `Models`, run `go run github.com/carlosframework/rastrillo/cmd/rastrillo migration generate`, then `... migration check`. Never edit a shipped migration.
-- **300-line cap on every `.go` and `.js` file** (`internal/guardstest`). Split by responsibility.
-- **`rst-*` attribute grammar**, never class spellings, except the seven class-only utilities. This app's components take `cal-`. Partials available from rastrillo: `field-text`, `field-textarea`, `field-select`, `field-check`, `form-foot`, `callout`, `page-header`, `detail-list`, `empty-state`, `badge` — see `settings.html` for the `dict` convention and the module cache `ui/partials/*.html` for exact keys (`field-textarea` takes `Name Label Value Rows Hint Error Required`).
-- **No affordance may require script.** `booking.js` is a debounced fetch plus a zone preselect and nothing else.
-- **Every range is half-open.** Use `DayBounds`, never `Add(24h)` for a day.
-- **Public zone input is bounded to `CommonZones`** (`zones.go`).
-- **Work inside the request.** No goroutines.
-- **`make ci` before every commit.** Run go with the sandbox off if the build cache is read-only. Commit trailer: `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`.
-- **Tests** live in `internal/calendartest` unless they need unexported names. Helpers there: `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 check `to` as well.
+- **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`.
+- **Hold and reschedule run inside `Store.holdMu`** (`sync.Mutex` on `Store`), with caps and the slot check inside the lock.
+- **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.
+- **Never `sessions.UserID`.** `CurrentPerson(r)`.
+- **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`.
+- **300-line cap on every `.go` and `.js`.** Split by responsibility.
+- **`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.
+- **No affordance may require script.** `booking.js` is a debounced fetch plus a zone preselect.
+- **Every range is half-open.** `DayBounds`, never `Add(24h)`.
+- **Public zone input** is `CommonZones` ∪ {host zone}.
+- **Work inside the request.** The one goroutine allowed is `timedSender`'s, and the request WAITS on it (§6).
+- **`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>`.
+- **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.
---
@@ -37,42 +38,34 @@ Copied from AGENTS.md and the spec. Every task inherits them.
| File | Responsibility | Task |
|---|---|---|
-| `internal/calendar/models_booking.go` | `BookingPage`, `BookingWindow`, `Booking`, `Window`, state + mail constants | 1 |
-| `internal/calendar/models.go` | `Person.Handle`; register models | 1 |
-| `internal/calendar/seal/seal.go` | purposes | 1 |
+| `internal/calendar/models_booking.go` | `BookingPage`, `BookingWindow`, `Booking`, `BookingMail`, `Window`, constants | 1 |
+| `internal/calendar/models.go`, `seal/seal.go`, `testing.go` | `Person.Handle`; register; purposes; test hooks | 1 |
| `internal/calendar/migrations/0005_*.sql`, `schema.sql` | generated | 1 |
-| `internal/calendar/store_booking_page.go` | handle, page CRUD, publish, public lookup | 1 |
-| `internal/calendar/testing.go` | `StoreForTest`, `PersonByAddressForTest` | 1 |
-| `internal/calendar/clock.go` | `Zone()` cache made safe, failures uncached | 2 |
-| `internal/calendar/avail.go`, `avail_windows.go` | parser; merge + describe | 2 |
-| `internal/calendar/templates/_bookingpane.html` | pane fragment | 2 |
-| `internal/calendar/slots.go` | `Slot`, `Busy`, `SlotRule`, `ComputeSlots` | 3 |
-| `internal/calendar/store_slots.go` | `BusyFor`, `FreeSlots` | 3 |
+| `internal/calendar/store_booking_page.go` | handle, page CRUD, publish, public lookup, ownership | 1 |
+| `internal/calendar/clock.go`, `zones.go` | `Zone()` safe; `IsCommonZone` | 2 |
+| `internal/calendar/avail.go`, `avail_windows.go`, `templates/_bookingpane.html` | parser; merge/describe; pane | 2 |
+| `internal/calendar/store_range.go` | override-of-finished-series fix | 3 |
+| `internal/calendar/slots.go`, `store_slots.go` | `ComputeSlots`; `BusyFor`; `FreeSlots` | 3 |
| `internal/calendar/store_booking_flow.go` | hold, confirm, reschedule, cancel | 4 |
-| `internal/calendar/store_booking_read.go` | `BookingByToken`, `BookingView`, `bookingByEvent` | 4 |
-| `internal/calendar/store_booking_sweep.go` | expire, owed mail, forget IPs, counts, `NextHoldExpiry` | 4 |
-| `internal/calendar/store_reminder_next.go` | `NextDueAt` widened | 4 |
-| `internal/calendar/store_event.go` | `UpdateEvent` refuses RRule on booked; `DeleteEvent` cancels booking | 4 |
-| `internal/calendar/handlers_booking_settings.go`, `handlers_booking_form.go` | host UI | 5 |
-| `internal/calendar/templates/booking_pages.html`, `booking_page_form.html`, `settings.html` | host UI | 5 |
-| `internal/calendar/static/booking.js` | pane fetch; zone preselect | 5 |
-| `internal/calendar/handlers_booking_public.go`, `handlers_booking_manage.go`, `handlers_booking_util.go` | public flow | 6 |
-| `internal/calendar/booking_ics.go`, `booking_mail.go`, `templates/mail/*.txt` | ICS + mail | 6 |
-| `internal/calendar/templates/book_page.html`, `book_sent.html`, `book_hold.html`, `book_manage.html`, `book_gone.html` | public UI | 6 |
-| `internal/calendar/handlers_jobs.go` | sweep steps in `drain`/`DrainOnRequest` | 7 |
+| `internal/calendar/store_booking_read.go` | `BookingByToken`, `BookingView`, counts | 4 |
+| `internal/calendar/store_booking_mail.go` | queue, claim, ack, `NextBookingMailDue` | 4 |
+| `internal/calendar/store_booking_sweep.go` | expire, forget IPs, `NextHoldExpiry`, `NextIPForget` | 4 |
+| `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 |
+| `internal/calendar/handlers_booking_settings.go`, `handlers_booking_form.go`, templates, `booking.js` | host UI | 5 |
+| `internal/calendar/handlers_booking_public.go`, `handlers_booking_manage.go`, `handlers_booking_util.go` | public flow; IP; bucket; 404 | 6 |
+| `internal/calendar/booking_ics.go`, `booking_mail.go`, `mail_timed.go`, `templates/mail/*.txt` | ICS; mail rendering; `timedSender` | 6 |
+| `internal/calendar/templates/book_*.html` | public UI | 6 |
+| `internal/calendar/handlers_jobs.go` | drain ordering; `DrainOnRequest` one thing | 7 |
| `internal/calendar/routes.go`, `render.go`, `templates/layout.html`, `static/calendar.css` | wiring | 5, 6 |
-| `internal/calendartest/booking_store_test.go`, `avail_test.go`, `slots_test.go`, `booking_flow_test.go`, `booking_http_test.go`, `booking_abuse_test.go`, `booking_sweep_test.go`, `zone_test.go` | tests | 1–7 |
-| `internal/calendartest/events_test.go` | leak fixture | 7 |
-| `test/browser/booking.mjs`, `internal/calendartest/playwright_test.go`, `Makefile`, `package.json` | browser passes | 7 |
+| 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 |
+| `test/browser/booking.mjs`, `playwright_test.go`, `Makefile`, `package.json` | browser | 7 |
| `AGENTS.md` | §11 | 8 |
---
-### Task 1: Models, migration, purposes, page store
+### Task 1: Models, migration, purposes, page store, ownership
-Spec §2.1–2.3, §7.3 (publish).
-
-**Files:** create `models_booking.go`, `store_booking_page.go`; modify `models.go`, `seal/seal.go`, `testing.go`; generate migration; test `booking_store_test.go`.
+Spec §2.1–2.6, §7.4 (publish).
**Produces:**
@@ -83,25 +76,33 @@ const (
BookingConfirmed = "confirmed"
BookingCancelled = "cancelled"
BookingExpired = "expired"
- BookingFailed = "failed"
- MailNone = ""
+ CancelledByNobody = ""
+ CancelledByBooker = "booker"
+ CancelledByHost = "host"
+
MailHold = "hold"
MailConfirmed = "confirmed"
MailRescheduled = "rescheduled"
MailCancelled = "cancelled"
+ ToBooker = "booker"
+ ToHost = "host"
HoldTTL = 30 * time.Minute
+ MinNotice = 10 * time.Minute
MaxReschedules = 5
IPForgetAfter = 24 * time.Hour
+ MailGaveUp = int64(-1) // sent_at value meaning "stopped trying"
+
+ MaxPageTitleBytes = 100
+ MaxPageDescBytes = 1000
+ MaxBookerNameRunes = 60
+ MaxBookerNameBytes = 80
+ MaxNoteRunes = 1000
+ MaxNoteBytes = 4000
)
-// Window is a weekly availability window, the plain value BookingWindow rows are read into.
-type Window struct {
- Weekday time.Weekday
- StartMin int
- EndMin int
-}
+type Window struct{ Weekday time.Weekday; StartMin, EndMin int }
type BookingPage struct {
ID int64 `gorm:"primaryKey"`
@@ -132,6 +133,7 @@ type Booking struct {
PageID int64 `gorm:"column:page_id;not null;index"`
EventID int64 `gorm:"column:event_id;not null;index"`
TokenHash string `gorm:"column:token_hash;not null;uniqueIndex"`
+ TokenSealed []byte `gorm:"column:token_sealed"`
State string `gorm:"column:state;not null;index"`
BookerNameSealed []byte `gorm:"column:booker_name_sealed"`
BookerAddressSealed []byte `gorm:"column:booker_address_sealed"`
@@ -141,14 +143,27 @@ type Booking struct {
HoldExpiresAt int64 `gorm:"column:hold_expires_at;not null;index"`
ConfirmedAt *int64 `gorm:"column:confirmed_at"`
CancelledAt *int64 `gorm:"column:cancelled_at"`
- PendingMail string `gorm:"column:pending_mail;not null"`
- MailAttempts int `gorm:"column:mail_attempts;not null"`
+ CancelledBy string `gorm:"column:cancelled_by;not null"`
IPFP *string `gorm:"column:ip_fp;index"`
+ IPForgetAt int64 `gorm:"column:ip_forget_at;not null;index"`
Reschedules int `gorm:"column:reschedules;not null"`
CreatedAt time.Time `gorm:"column:created_at;not null"`
UpdatedAt time.Time `gorm:"column:updated_at;not null"`
}
+type BookingMail struct {
+ ID int64 `gorm:"primaryKey"`
+ BookingID int64 `gorm:"column:booking_id;not null;uniqueIndex:idx_booking_mail_once"`
+ Kind string `gorm:"column:kind;not null;uniqueIndex:idx_booking_mail_once"`
+ Recipient string `gorm:"column:recipient;not null;uniqueIndex:idx_booking_mail_once"`
+ Revision int `gorm:"column:revision;not null;uniqueIndex:idx_booking_mail_once"`
+ DueAt int64 `gorm:"column:due_at;not null;index"`
+ Attempts int `gorm:"column:attempts;not null"`
+ ClaimedAt *int64 `gorm:"column:claimed_at"`
+ SentAt *int64 `gorm:"column:sent_at"`
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+}
+
// models.go, on Person after Zone:
Handle *string `gorm:"column:handle;uniqueIndex"`
@@ -156,6 +171,7 @@ Handle *string `gorm:"column:handle;uniqueIndex"`
PurposeBookingTitle = "booking.title"
PurposeBookingDescription = "booking.description"
PurposeBookingAvailability = "booking.availability"
+PurposeBookingToken = "booking.token"
PurposeBookerName = "booking.booker.name"
PurposeBookerAddress = "booking.booker.address"
PurposeBookerNote = "booking.booker.note"
@@ -163,7 +179,7 @@ PurposeBookingIP = "booking.ip"
// store_booking_page.go
type BookingPageInput struct {
- Title, Description, Availability string
+ Title, Description, Availability string
DurationMin, BufferMin, NoticeMin, HorizonDays int
}
type BookingPageView struct {
@@ -174,248 +190,51 @@ type BookingPageView struct {
}
var ErrHandleTaken = errors.New("calendar: handle taken")
-func ValidateHandle(h string) error // ^[a-z0-9-]{2,32}$, not in {m,new,static}; ValidationError{"handle", msg}
-func ValidateBookingPageInput(in *BookingPageInput) error // trims; title 1..120; description <= 1000; duration 5..480; buffer 0..120; notice 0..10080; horizon 1..90
-func newPageCode() string // 8 chars of [a-z0-9] from crypto/rand
-func (s *Store) SaveHandle(ctx context.Context, personID int64, handle string) error
-func (s *Store) PersonByHandle(ctx context.Context, handle string) (Person, error)
-func (s *Store) CreateBookingPage(ctx context.Context, personID int64, in BookingPageInput, windows []Window) (BookingPage, error)
-func (s *Store) UpdateBookingPage(ctx context.Context, personID, pageID int64, in BookingPageInput, windows []Window) error
-func (s *Store) PublishBookingPage(ctx context.Context, personID, pageID int64, publish bool) error
-func (s *Store) BookingPages(ctx context.Context, personID int64) ([]BookingPageView, error)
-func (s *Store) BookingPage(ctx context.Context, personID, pageID int64) (BookingPageView, error)
-func (s *Store) PublicBookingPage(ctx context.Context, handle, code string) (BookingPageView, Person, error) // published only; ErrNotFound for every miss
+func ValidateHandle(h string) error // ^[a-z0-9-]{2,32}$ after lowercasing; not m/new/static
+func 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
+func newPageCode() string // 8 × [a-z0-9], crypto/rand
+func (s *Store) SaveHandle(ctx, personID int64, handle string) error
+func (s *Store) PersonByHandle(ctx, handle string) (Person, error)
+func (s *Store) CreateBookingPage(ctx, personID int64, in BookingPageInput, windows []Window) (BookingPage, error)
+func (s *Store) UpdateBookingPage(ctx, personID, pageID int64, in BookingPageInput, windows []Window) error
+func (s *Store) PublishBookingPage(ctx, personID, pageID int64, publish bool) error
+func (s *Store) BookingPages(ctx, personID int64) ([]BookingPageView, error)
+func (s *Store) BookingPage(ctx, personID, pageID int64) (BookingPageView, error)
+func (s *Store) PublicBookingPage(ctx, handle, code string) (BookingPageView, Person, error)
// testing.go
func (a *App) StoreForTest() *Store
func (a *App) PersonByAddressForTest(t TB, address string) Person
```
-- [ ] **Step 1: Failing tests** — `internal/calendartest/booking_store_test.go`:
-
-```go
-package calendartest
-
-import (
- "context"
- "testing"
- "time"
-
- calendar "amadan.net/oficina/calendar/internal/calendar"
-)
-
-func TestABookingPageRoundTripsSealedWithItsWindows(t *testing.T) {
- app, _ := signedIn(t, "host@example.com")
- ctx := context.Background()
- st := app.StoreForTest()
- host := app.PersonByAddressForTest(t, "host@example.com")
- if err := st.SaveHandle(ctx, host.ID, "paul"); err != nil {
- t.Fatalf("SaveHandle: %v", err)
- }
- in := calendar.BookingPageInput{Title: "Quick chat", Description: "Fifteen minutes.",
- Availability: "mondays 12-3", DurationMin: 15, BufferMin: 10, NoticeMin: 120, HorizonDays: 30}
- windows := []calendar.Window{{Weekday: time.Monday, StartMin: 720, EndMin: 900}}
- page, err := st.CreateBookingPage(ctx, host.ID, in, windows)
- if err != nil {
- t.Fatalf("CreateBookingPage: %v", err)
- }
- if len(page.Code) != 8 {
- t.Errorf("code %q, want eight generated characters", page.Code)
- }
- // Unpublished: invisible.
- if _, _, err := st.PublicBookingPage(ctx, "paul", page.Code); !calendar.NotFound(err) {
- t.Errorf("unpublished page: err = %v, want ErrNotFound", err)
- }
- if err := st.PublishBookingPage(ctx, host.ID, page.ID, true); err != nil {
- t.Fatal(err)
- }
- got, who, err := st.PublicBookingPage(ctx, "paul", page.Code)
- if err != nil {
- t.Fatalf("PublicBookingPage: %v", err)
- }
- if who.ID != host.ID || got.Title != "Quick chat" || got.Availability != "mondays 12-3" ||
- len(got.Windows) != 1 || got.Windows[0].EndMin != 900 || !got.Published {
- t.Errorf("round trip lost something: %+v", got)
- }
- if _, err := app.DBForTest().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
- t.Fatal(err)
- }
- for _, needle := range []string{"Quick chat", "Fifteen minutes.", "mondays 12-3"} {
- if found, where := app.GrepDatabaseForTest(t, needle); found {
- t.Errorf("%q is plaintext in %s", needle, where)
- }
- }
-}
-
-func TestUnknownHandleUnknownCodeAndUnpublishedAllVanishTheSameWay(t *testing.T) {
- app, _ := signedIn(t, "host@example.com")
- ctx := context.Background()
- st := app.StoreForTest()
- host := app.PersonByAddressForTest(t, "host@example.com")
- _ = st.SaveHandle(ctx, host.ID, "paul")
- page, _ := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{Title: "Chat", DurationMin: 30, HorizonDays: 30}, nil)
- _ = st.PublishBookingPage(ctx, host.ID, page.ID, true)
- _ = st.PublishBookingPage(ctx, host.ID, page.ID, false)
- for _, c := range [][2]string{{"paul", page.Code}, {"nobody", page.Code}, {"paul", "zzzzzzzz"}} {
- if _, _, err := st.PublicBookingPage(ctx, c[0], c[1]); !calendar.NotFound(err) {
- t.Errorf("%v: err = %v, want ErrNotFound", c, err)
- }
- }
-}
-
-func TestAHandleIsUniqueLowercaseAndNotReserved(t *testing.T) {
- app, _ := signedIn(t, "a@example.com")
- ctx := context.Background()
- st := app.StoreForTest()
- a := app.PersonByAddressForTest(t, "a@example.com")
- app.SignInForTest(t, "b@example.com")
- b := app.PersonByAddressForTest(t, "b@example.com")
- if err := st.SaveHandle(ctx, a.ID, "Paul"); err != nil { // lowercased on save
- t.Fatal(err)
- }
- if p, _ := st.PersonByHandle(ctx, "paul"); p.ID != a.ID {
- t.Error("handle was not lowercased or not saved")
- }
- if err := st.SaveHandle(ctx, b.ID, "paul"); err != calendar.ErrHandleTaken {
- t.Errorf("second claim: err = %v, want ErrHandleTaken", err)
- }
- for _, bad := range []string{"m", "new", "static", "a", "has space", "üñí"} {
- if err := calendar.ValidateHandle(bad); err == nil {
- t.Errorf("%q must be refused", bad)
- }
- }
- if err := st.SaveHandle(ctx, a.ID, ""); err != nil {
- t.Errorf("clearing a handle: %v", err)
- }
-}
-
-func TestAnotherPersonCannotEditOrPublishMyPage(t *testing.T) {
- app, _ := signedIn(t, "me@example.com")
- ctx := context.Background()
- st := app.StoreForTest()
- me := app.PersonByAddressForTest(t, "me@example.com")
- app.SignInForTest(t, "them@example.com")
- them := app.PersonByAddressForTest(t, "them@example.com")
- page, _ := st.CreateBookingPage(ctx, me.ID, calendar.BookingPageInput{Title: "Mine", DurationMin: 30, HorizonDays: 30}, nil)
- if err := st.PublishBookingPage(ctx, them.ID, page.ID, true); !calendar.NotFound(err) {
- t.Errorf("publish as another person: %v, want ErrNotFound", err)
- }
- if err := st.UpdateBookingPage(ctx, them.ID, page.ID, calendar.BookingPageInput{Title: "Theirs", DurationMin: 30, HorizonDays: 30}, nil); !calendar.NotFound(err) {
- t.Errorf("update as another person: %v, want ErrNotFound", err)
- }
- if _, err := st.BookingPage(ctx, them.ID, page.ID); !calendar.NotFound(err) {
- t.Errorf("read as another person: %v, want ErrNotFound", err)
- }
-}
-```
-
-- [ ] **Step 2: Run** `go test ./internal/calendartest/ -run 'BookingPage|Handle|MyPage' -count=1` — expect compile errors.
-- [ ] **Step 3: Models, purposes, `Person.Handle`, `Models` registration, `testing.go` helpers.** `PersonByAddressForTest` does `First(&p, "address = ?", normalizeAddress(address))` and `t.Fatalf` on miss.
-- [ ] **Step 4: Generate the migration** (`rm schema.sql`; `migration generate`; `migration check`). Open the SQL: `handle` nullable + unique index; `ip_fp` nullable; `published_at` nullable.
-- [ ] **Step 5: Implement `store_booking_page.go`.** `SaveHandle` lowercases and trims, `""` writes NULL, `UNIQUE` error → `ErrHandleTaken`. `CreateBookingPage`: validate; seal three columns; `Code: newPageCode()`; page + windows in one `Transaction`; on a `UNIQUE` collision of the code (astronomically rare) regenerate once. `UpdateBookingPage`, `PublishBookingPage`, `BookingPage`: `Where("id = ? AND person_id = ?", ...)`; a miss is `ErrNotFound`; updates by explicit map (`title_sealed`, `description_sealed`, `duration_min`, `buffer_min`, `notice_min`, `horizon_days`, `availability_text_sealed`, `updated_at`); windows deleted and re-inserted in the same transaction. `PublishBookingPage` writes `published_at = now` or `NULL`. `PublicBookingPage`: `PersonByHandle` → `First(&page, "person_id = ? AND code = ? AND published_at IS NOT NULL")` → open → windows ordered by `weekday, start_min`.
-- [ ] **Step 6: Run; pass.** **Step 7: `make ci`; commit** `booking: pages, windows and bookings — the tables, sealed where they are content`.
+- [ ] **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).
+- [ ] **Step 2: Run; compile failure.**
+- [ ] **Step 3: Models, purposes, `Handle`, `Models` registration (all four new models), `testing.go` hooks.**
+- [ ] **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`.
+- [ ] **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.
+- [ ] **Step 6: Pass. `make ci`. Commit** `booking: pages, windows, bookings and their mail — the tables, sealed where they are content`.
---
-### Task 2: `Zone()` made safe; the availability parser; the pane fragment
-
-Spec §3, §5.1 (bounded zone), §9 (`Zone()` test).
+### Task 2: `Zone()` made safe; availability parser; pane
-**Files:** modify `clock.go:55-81`; create `avail.go`, `avail_windows.go`, `templates/_bookingpane.html`; modify `render.go` (`fragments` and every page's ParseFS); tests `zone_test.go`, `avail_test.go`.
-
-**Consumes:** `consumed`, `newConsumed`, `weekdayAlt`, `weekdays`, `rangeSep` from `quickadd*.go`. **Does NOT use** `reRange`, `clock`, `applyMeridiem` (they forgive; §3.1).
+Spec §3, §5.1, §9.
**Produces:**
-
```go
-func ParseAvailability(text string) (windows []Window, problems []string) // windows merged + sorted
-func MergeWindows(ws []Window) []Window // per weekday, overlapping/touching → one
-func DescribeWindows(ws []Window) []string // "Monday 12:00–15:00, 17:00–19:00" per weekday, Sun..Sat
+func ParseAvailability(text string) (windows []Window, problems []string)
+func MergeWindows(ws []Window) []Window
+func DescribeWindows(ws []Window) []string
type BookingPaneView struct{ Lines, Problems []string; Empty bool }
func bookingPaneFor(text string) BookingPaneView
-func IsCommonZone(name string) bool // zones.go
+func IsCommonZone(name string) bool // zones.go
+func ZoneCacheSizeForTest() int // testing.go
```
-- [ ] **Step 1: `Zone()` test** — `internal/calendartest/zone_test.go`:
-
-```go
-func TestZoneIsSafeUnderConcurrentGarbage(t *testing.T) {
- var wg sync.WaitGroup
- for i := 0; i < 100; i++ {
- wg.Add(1)
- go func(i int) {
- defer wg.Done()
- _, _ = calendar.Zone(fmt.Sprintf("Nowhere/Town%d", i))
- _, _ = calendar.Zone("Europe/Dublin")
- }(i)
- }
- wg.Wait()
- if n := calendar.ZoneCacheSizeForTest(); n > 40 {
- t.Errorf("cache holds %d entries after 100 garbage names: failures are being cached", n)
- }
- if !calendar.IsCommonZone("Europe/Dublin") || calendar.IsCommonZone("Nowhere/Town1") {
- t.Error("IsCommonZone is wrong")
- }
-}
-```
-Run it with `-race`: `go test -race ./internal/calendartest/ -run ZoneIsSafe -count=1`. Expected: a data race report from the current map.
-
-- [ ] **Step 2: Fix `clock.go`**: replace the closure's map with a `sync.Map` (or `sync.RWMutex` + map); store ONLY successes; add `func ZoneCacheSizeForTest() int` in `testing.go` (range-count the sync.Map). Add `IsCommonZone` to `zones.go` (a `map[string]bool` built from `CommonZones` in `init`).
-
-- [ ] **Step 3: Parser tests** — `avail_test.go`:
-
-```go
-func w(d time.Weekday, from, to string) calendar.Window { return calendar.Window{Weekday: d, StartMin: mins(from), EndMin: mins(to)} }
-func mins(hhmm string) int { t, err := time.Parse("15:04", hhmm); if err != nil { panic(err) }; return t.Hour()*60 + t.Minute() }
-func allDays(from, to string) []calendar.Window { var out []calendar.Window; for d := time.Sunday; d <= time.Saturday; d++ { out = append(out, w(d, from, to)) }; return out }
-
-func TestAvailabilityReadsTheSentence(t *testing.T) {
- cases := []struct{ text string; want []calendar.Window }{
- {"mondays between 12–3, tuesdays 12–2, 5-7", []calendar.Window{w(time.Monday, "12:00", "15:00"), w(time.Tuesday, "12:00", "14:00"), w(time.Tuesday, "17:00", "19:00")}},
- {"mon-wed 9-5", []calendar.Window{w(time.Monday, "09:00", "17:00"), w(time.Tuesday, "09:00", "17:00"), w(time.Wednesday, "09:00", "17:00")}},
- {"weekdays 10am to 4pm", []calendar.Window{w(time.Monday, "10:00", "16:00"), w(time.Tuesday, "10:00", "16:00"), w(time.Wednesday, "10:00", "16:00"), w(time.Thursday, "10:00", "16:00"), w(time.Friday, "10:00", "16:00")}},
- {"every day 8–6", allDays("08:00", "18:00")},
- {"saturday 10:30–13:00; sunday 14-16", []calendar.Window{w(time.Sunday, "14:00", "16:00"), w(time.Saturday, "10:30", "13:00")}},
- {"weekends 10-12", []calendar.Window{w(time.Sunday, "10:00", "12:00"), w(time.Saturday, "10:00", "12:00")}},
- {"Friday 9am-12pm, 1pm-3pm and 4-5", []calendar.Window{w(time.Friday, "09:00", "12:00"), w(time.Friday, "13:00", "15:00"), w(time.Friday, "16:00", "17:00")}},
- {"tues 12 to 2", []calendar.Window{w(time.Tuesday, "12:00", "14:00")}},
- {"every tuesday 12-2", []calendar.Window{w(time.Tuesday, "12:00", "14:00")}},
- {"sun-tue 9-10", []calendar.Window{w(time.Sunday, "09:00", "10:00"), w(time.Monday, "09:00", "10:00"), w(time.Tuesday, "09:00", "10:00")}},
- {"monday 9-12, monday 10-13", []calendar.Window{w(time.Monday, "09:00", "13:00")}}, // merged
- {"monday 9-12, 12-1", []calendar.Window{w(time.Monday, "09:00", "13:00")}}, // touching → merged
- {"monday 22:00-24:00", []calendar.Window{w(time.Monday, "22:00", "23:59")}}, // see note
- {"monday 0-1am", []calendar.Window{w(time.Monday, "00:00", "01:00")}},
- }
- // ...loop as in Task 1's style; windows are sorted Sunday-first then by StartMin.
-}
-```
-Note on `24:00`: `mins("24:00")` cannot parse, so express the expectation as `calendar.Window{Weekday: time.Monday, StartMin: 1320, EndMin: 1440}` for that case, not via `w(...)`. Remove the `"23:59"` placeholder above when writing the file.
-
-```go
-func TestAvailabilityRefusesWhatItCannotRead(t *testing.T) {
- cases := []struct{ text, quoted string }{
- {"mondays 10pm-2", "10pm-2"}, // crosses midnight
- {"mondays 12-3, whenever", "whenever"},
- {"12-3", "12-3"}, // no day, nothing to inherit
- {"mondays 25-26", "25-26"},
- {"mondays 9:60-10", "9:60-10"},
- {"mondays 24:00-1", "24:00-1"}, // 24:00 only at the END
- {"mondays 9-9", "9-9"}, // empty
- {"mondays 9-5 in the office", "in the office"},
- }
- // each must produce >= 1 problem containing quoted
- _, problems := calendar.ParseAvailability("mondays whenever, tuesdays sometime")
- if len(problems) != 2 { t.Errorf("problems = %v, want two", problems) }
-}
-
-func TestDescribeWindowsGroupsByDay(t *testing.T) {
- ws, _ := calendar.ParseAvailability("tuesdays 12–2, 5-7, mondays 9-10")
- got := strings.Join(calendar.DescribeWindows(ws), "|")
- if got != "Monday 09:00–10:00|Tuesday 12:00–14:00, 17:00–19:00" { t.Errorf("got %q", got) }
-}
-```
-
-- [ ] **Step 4: Implement.** `avail.go` (parser) and `avail_windows.go` (merge/describe) — keep each under 300 lines.
+- [ ] **Step 1: `zone_test.go`**: 100 goroutines resolve `Nowhere/TownN` and `Europe/Dublin`; run with `-race`; afterwards `ZoneCacheSizeForTest() <= len(CommonZones)+5`.
+- [ ] **Step 2: `clock.go`**: `sync.Map`, successes only. `zones.go`: `IsCommonZone`.
+- [ ] **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`.
+- [ ] **Step 4: Implement** `avail.go` + `avail_windows.go`:
```go
var (
@@ -423,106 +242,84 @@ var (
reAvailGroup = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(weekdays|weekends|every\s+day|daily|all\s+days)\b`)
reAvailDayRange = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?` + rangeSep + `(` + weekdayAlt + `)s?\b`)
reAvailDay = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?\b`)
- // A time range with RAW components; nothing is clamped here.
- 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`)
- reAvailFiller = regexp.MustCompile(`(?i)\b(between|from|and|on|at)\b`)
+ 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`)
+ reAvailFiller = regexp.MustCompile(`(?i)\b(between|from|and|on|at)\b`)
)
-// readClock validates raw components. ok=false on hour>24, minute>59,
-// or 24 with a non-zero minute. 24:00 is returned as 1440 and the CALLER
-// decides whether the position allows it.
-func readClock(hh, mm, meridiem string, inferPM bool) (minutes int, ok bool)
-```
-Meridiem rule: if either end has an explicit meridiem, the other end without one takes the same; if neither has one, `inferPM` applies to hours 1–7. `end <= start` → problem quoting the matched range text. `24:00` allowed only as end. Every problem: `fmt.Sprintf("Could not read %q", what)`.
-
-`MergeWindows`: sort by weekday then StartMin; merge when `next.StartMin <= cur.EndMin`. `DescribeWindows` uses `fmt.Sprintf("%02d:%02d–%02d:%02d")` and prints `24:00` as `24:00`.
-
-- [ ] **Step 5: The fragment.** `_bookingpane.html`:
-```html
-{{define "bookingpane"}}{{if .Empty}}<p cal-book-blank>Type when you are free — for example <q>mondays 12–3, tuesdays 12–2, 5–7</q>.</p>{{else}}{{with .Problems}}<ul cal-book-problems role="alert">{{range .}}<li>{{.}}</li>{{end}}</ul>{{end}}{{with .Lines}}<ul cal-book-lines>{{range .}}<li>{{.}}</li>{{end}}</ul>{{end}}{{end}}{{end}}
+// readClock validates RAW components; nothing is clamped.
+// meridiem != "": hour 1..12, minute 0..59; 12am → 0, 12pm → 12.
+// meridiem == "": hour 0..24 (24 only with minute 0 and only when atEnd), minute 0..59;
+// if inferPM and 1 <= hour <= 7, hour += 12 — on the HOUR COMPONENT, whatever the minutes.
+func readClock(hh, mm, meridiem string, inferPM, atEnd bool) (minutes int, ok bool)
```
-Add it to `fragments` and to the page ParseFS list in `render.go` beside `_quickpane.html`.
+Range 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`.
-- [ ] **Step 6: Run all; pass. `make ci`; commit** `booking: one parser for "mondays 12–3", and Zone() stops caching garbage`.
+- [ ] **Step 5: `_bookingpane.html`** (as in revision-2 plan) into `fragments` and every page ParseFS.
+- [ ] **Step 6: Pass. `make ci`. Commit** `booking: one parser for "mondays 12–3", and Zone() stops caching garbage`.
---
-### Task 3: Slot arithmetic
-
-Spec §4.
+### Task 3: The range-query fix; slot arithmetic
-**Files:** create `slots.go`, `store_slots.go`; test `slots_test.go`.
+Spec §4, §4.1, §4.2 (day validation only; the bucket is Task 6).
**Produces:**
-
```go
type Slot struct{ StartUTC, EndUTC int64; Local time.Time }
type Busy struct{ StartUTC, EndUTC int64 }
type SlotRule struct{ DurationMin, BufferMin, NoticeMin, HorizonDays int; Zone string; Windows []Window }
func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot, error)
-func (s *Store) BusyFor(ctx context.Context, hostID, from, to int64, now time.Time, excludeEventID int64) ([]Busy, error)
-func (s *Store) FreeSlots(ctx context.Context, page BookingPageView, host Person, now, from, to time.Time, excludeEventID int64) ([]Slot, error)
+func (s *Store) BusyFor(ctx, hostID int64, from, to int64, now time.Time, excludeEventID int64) ([]Busy, error)
+func (s *Store) FreeSlots(ctx, page BookingPageView, host Person, now, from, to time.Time, excludeEventID int64) ([]Slot, error)
func ruleFor(page BookingPageView, host Person) SlotRule
```
-- [ ] **Step 1: Tests** — `slots_test.go`. Use `dublin := mustZone("Europe/Dublin")`, `mon := time.Date(2026, 9, 7, 0, 0, 0, 0, dublin)` (a Monday, IST), `mondayRule(dur, buffer)` with a 12:00–15:00 Monday window, helpers `starts(slots) []string` formatting `Local` as `"Mon 15:04"`, and `eq`.
-
- - `TestSlotsStepByDurationInsideTheWindow`: 60-min → `12:00, 13:00, 14:00`.
- - `TestABusyBlockRemovesTheSlotItOverlaps`: busy 13:00–13:30 → `12:00, 14:00`.
- - `TestTheBufferPadsEverySlotBeforeTheOverlapTest`: same busy, buffer 10 → `14:00` only.
- - `TestABusyBlockJustBeforeTheRangeStillCountsWhenBuffered`: `from = mon 12:00`, busy 11:50–11:55, buffer 10 → `13:00, 14:00` (12:00 padded to 11:50 overlaps).
- - `TestNoticeAndHorizonAreTestedPerCandidate`: notice 120, now = mon 11:00, `to` = mon+14d, horizon 7 → `Mon 13:00, Mon 14:00` and nothing from the next Monday.
- - `TestSpringForwardSundayOffersOnlyWallClocksThatExist`: 29 March 2026, window 00:00–04:00, 60-min → the local labels Go produces once 01:00 is dropped; pin `slots[i].StartUTC` to `time.Date(...UTC).Unix()` values. **Write the test to print what it got, run it once against the implementation, then pin the printed numbers as the expectation with a comment explaining each** — that is the `clock_test.go` convention.
- - `TestFoldSundayKeepsTheOccurrenceToUTCWouldStore`: 25 October 2026, window 00:00–04:00. Every offered start must satisfy `calendar.ToUTC(calendar.FormatLocal(slot.Local, false), "Europe/Dublin", false) == slot.StartUTC`. Assert that for each slot, and pin the count.
- - `TestATokyoHostIsBookedOnTheBookersSundayButTheHostsMonday`: rule zone `Asia/Tokyo`, Monday 09:00–10:00 window → one slot; `time.Unix(slot.StartUTC,0).In(LA).Weekday() == time.Sunday`.
-
-- [ ] **Step 2: Run; confirm failure.**
-- [ ] **Step 3: Implement `slots.go`:**
+- [ ] **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).
+- [ ] **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.
+- [ ] **Step 3: `slots_test.go`** (helpers `dublin`, `mon = 2026-09-07 Dublin`, `mondayRule(dur, buffer)` with 12:00–15:00 Monday, `starts()`, `eq()`):
+ - step-by-duration → `12:00, 13:00, 14:00`
+ - busy 13:00–13:30 → `12:00, 14:00`
+ - buffer 10 → `14:00`
+ - busy 11:50–11:55 with `from = 12:00`, buffer 10 → `13:00, 14:00`
+ - notice floor: `NoticeMin: 0`, now = 11:55 → 12:00 is NOT offered (`MinNotice` is 10 min)
+ - notice 120, now 11:00, horizon 7, `to` = +14d → `Mon 13:00, Mon 14:00` only
+ - `to` bounds start: rule window Monday 23:00–24:00, 60-min, `to` = Tuesday 00:00 local → the 23:00 slot IS offered
+ - 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
+ - fold 2026-10-25 with 00:00–04:00 / 60: same assertions; additionally assert `(EndUTC-StartUTC) == 3600` for every slot
+ - Tokyo host Monday 09:00 → LA Sunday
+- [ ] **Step 4: Implement `slots.go`:**
```go
-func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot, error) {
- loc, err := Zone(rule.Zone)
- if err != nil { return nil, err }
- earliest := now.Add(time.Duration(rule.NoticeMin) * time.Minute)
- latest := now.In(loc).AddDate(0, 0, rule.HorizonDays)
- dur := time.Duration(rule.DurationMin) * time.Minute
- pad := time.Duration(rule.BufferMin) * time.Minute
- byDay := map[time.Weekday][]Window{}
- for _, w := range rule.Windows { byDay[w.Weekday] = append(byDay[w.Weekday], w) }
- var out []Slot
- f := from.In(loc)
- for day := time.Date(f.Year(), f.Month(), f.Day(), 0, 0, 0, 0, loc); day.Before(to); day = day.AddDate(0, 0, 1) {
- for _, w := range byDay[day.Weekday()] {
- for m := w.StartMin; m+rule.DurationMin <= w.EndMin; m += rule.DurationMin {
- start := time.Date(day.Year(), day.Month(), day.Day(), 0, m, 0, 0, loc)
- // Round-trip rule (§4.3): what is offered must be what the record can hold.
- if start.Hour()*60+start.Minute() != m { continue }
- if utc, err := ToUTC(FormatLocal(start, false), rule.Zone, false); err != nil || utc != start.Unix() { continue }
- end := start.Add(dur)
- if start.Before(earliest) || start.Before(from) || end.After(to) || end.After(latest) { continue }
- if overlapsAny(start.Add(-pad).Unix(), end.Add(pad).Unix(), busy) { continue }
- out = append(out, Slot{StartUTC: start.Unix(), EndUTC: end.Unix(), Local: start})
- }
- }
- }
- return out, nil
+for m := w.StartMin; m+rule.DurationMin <= w.EndMin; m += rule.DurationMin {
+ start := time.Date(day.Year(), day.Month(), day.Day(), 0, m, 0, 0, loc)
+ end := start.Add(dur)
+ if !wallIs(start, day, m) || !wallIs(end, day, m+rule.DurationMin) { continue }
+ if !roundTrips(start, rule.Zone) || !roundTrips(end, rule.Zone) { continue }
+ if start.Before(earliest) || start.Before(from) || !start.Before(to) || end.After(latest) { continue }
+ if overlapsAny(start.Add(-pad).Unix(), end.Add(pad).Unix(), busy) { continue }
+ out = append(out, Slot{start.Unix(), end.Unix(), start})
}
+// 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.
+// roundTrips: u, err := ToUTC(FormatLocal(t, false), zone, false); err == nil && u == t.Unix().
+// earliest := now.Add(max(MinNotice, NoticeMin)); latest := now.In(loc).AddDate(0,0,HorizonDays).
```
-`store_slots.go` `BusyFor`: `EventsInRange(ctx, hostID, from-86400, to+86400)`; skip `!Busy(e.Kind)`, `e.Status == StatusCancelled`, `e.ID == excludeEventID`; for `e.AllDay` derive `[DayBounds(startDate, host.Zone), DayBounds(endDate-1, host.Zone).end)` — i.e. parse `StartLocal`/`EndLocal` as dates and take the host-zone bounds of each day in `[start, end)`; for tentative events, exclude those whose booking is `held` with `hold_expires_at <= now` (one query up front: `SELECT event_id FROM bookings WHERE state='held' AND hold_expires_at <= ?` into a set). `ruleFor` copies the page's four knobs, `host.Zone`, `page.Windows`.
+`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.
-- [ ] **Step 4: Run; pin the DST numbers; pass. `make ci`; commit** `booking: free slots — the host's windows minus everything the host can see`.
+- [ ] **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`.
---
-### Task 4: The state machine, the outbox, the sweep queries, and the host's authority
-
-Spec §5.2–5.7 (store half), §6 (outbox columns), §7.1 (counts), §8 (queries).
+### Task 4: The state machine, the mail queue, the sweep queries, the host's authority
-**Files:** create `store_booking_flow.go`, `store_booking_read.go`, `store_booking_sweep.go`; modify `store_reminder_next.go`, `store_event.go` (`UpdateEvent`, `DeleteEvent`), `store.go` (add `holdMu sync.Mutex` to `Store`); test `booking_flow_test.go`.
+Spec §5.2–5.7 (store), §2.5, §6 (queue), §7.1 (counts), §8 (queries), §5.5 (host authority).
**Produces:**
```go
+// store.go
+type Store struct { g *gorm.DB; seal *seal.Sealer; logger *slog.Logger; holdMu sync.Mutex }
+
+// store_booking_flow.go
var (
ErrSlotTaken = errors.New("calendar: slot taken")
ErrOverCap = errors.New("calendar: too many open holds")
@@ -536,219 +333,197 @@ type HoldInput struct {
Note, BookerZone string
IPFP string
}
+func (s *Store) HoldBooking(ctx, in HoldInput, now time.Time) (Booking, error) // holdMu; caps; slot; tx incl. mail row; token returned via view
+func (s *Store) ConfirmBooking(ctx, bookingID int64, now time.Time) (changed bool, err error) // guard: held, expires > now, now < event.start
+func (s *Store) RescheduleBooking(ctx, bookingID, startUTC, endUTC int64, rev int, now time.Time) (changed bool, err error) // holdMu; ErrOverCap; ErrSlotTaken; rev mismatch → changed=false
+func (s *Store) CancelBooking(ctx, bookingID int64, by string, now time.Time) (changed bool, err error)
+func (s *Store) cancelBookingsForEvent(tx *gorm.DB, eventID int64, by string, now time.Time) error // used by UpdateEvent/DeleteEvent
+
+// store_booking_read.go
type BookingView struct {
Booking
Page BookingPageView
Host Person
- Event EventView // Status/StartUTC/EndUTC/Sequence/DeletedAt are what public code reads; NEVER Notes/Location/Attendees
+ HostName string
+ Event Event // the ROW, for Status/StartUTC/EndUTC/StartLocal/EndLocal/TZ/Sequence/DeletedAt only
+ EventUID string
Name, Address, Note string
- HostName string // opened NameSealed, or the handle
+ Token string // opened; empty unless withToken
}
type HoldCounts struct{ OpenIP, OpenPage, OpenAddress, AttemptsIP, AttemptsAddress int }
-
-func (s *Store) HoldBooking(ctx context.Context, in HoldInput, now time.Time) (token string, b Booking, err error) // holdMu; caps; slot; tx
-func (s *Store) FailHold(ctx context.Context, bookingID int64, now time.Time) error // held→failed, event soft-deleted
-func (s *Store) ConfirmBooking(ctx context.Context, bookingID int64, now time.Time) (changed bool, err error)
-func (s *Store) RescheduleBooking(ctx context.Context, bookingID, startUTC, endUTC int64, now time.Time) error // holdMu
-func (s *Store) CancelBooking(ctx context.Context, bookingID int64, now time.Time) (changed bool, err error)
-func (s *Store) BookingByToken(ctx context.Context, token string) (BookingView, error)
-func (s *Store) bookingIDForEvent(ctx context.Context, eventID int64) (int64, bool)
-func (s *Store) HoldCountsFor(ctx context.Context, pageID int64, ipFP, addressFP string, now time.Time) (HoldCounts, error)
-func (s *Store) ExpireHolds(ctx context.Context, now time.Time, limit int) (int, error)
-func (s *Store) ForgetOldIPs(ctx context.Context, now time.Time, limit int) (int, error)
-func (s *Store) OwedMail(ctx context.Context, limit int) ([]BookingView, error)
-func (s *Store) MarkMailSent(ctx context.Context, bookingID int64) error
-func (s *Store) MarkMailFailed(ctx context.Context, bookingID int64) error // attempts+1; at MaxReminderAttempts clear the marker
-func (s *Store) NextHoldExpiry(ctx context.Context) time.Time
-func (s *Store) MailIsOwed(ctx context.Context) bool
+func (s *Store) BookingByToken(ctx, token string) (BookingView, error)
+func (s *Store) BookingByID(ctx, id int64, withToken bool) (BookingView, error)
+func (s *Store) HoldCountsFor(ctx, pageID int64, ipFP, addressFP string, now time.Time) (HoldCounts, error)
+func (s *Store) bookingIDForEvent(ctx, eventID int64) (int64, bool)
+
+// store_booking_mail.go
+func queueBookingMail(tx *gorm.DB, bookingID int64, kind string, recipients []string, revision int, now time.Time) error
+func (s *Store) ClaimBookingMail(ctx, now int64) (BookingMail, bool, error) // lease = claimLease; oldest due first
+func (s *Store) MarkBookingMailSent(ctx, id int64, now int64) error // WHERE id=? AND sent_at IS NULL
+func (s *Store) MarkBookingMailFailed(ctx, id int64, now int64) error // attempts+1; at MaxReminderAttempts sent_at = MailGaveUp
+func (s *Store) NextBookingMailDue(ctx) time.Time
+func (s *Store) UnsentBookingMailForTest(bookingID int64) []BookingMail
+
+// store_booking_sweep.go
+func (s *Store) ExpireHolds(ctx, now time.Time, limit int) (int, error)
+func (s *Store) ForgetOldIPs(ctx, now time.Time, limit int) (int, error)
+func (s *Store) NextHoldExpiry(ctx) time.Time
+func (s *Store) NextIPForget(ctx) time.Time
```
-- [ ] **Step 1: Tests** — `booking_flow_test.go`. Helpers: `hostWithPage(t) (*calendar.App, calendar.Person, calendar.BookingPageView)` (handle `paul`, page `every day 9-5`, 30 min, buffer 0, notice 0, horizon 30, published) and `firstSlots(t, app, page, host, now, n) []calendar.Slot`. Tests:
-
- - `TestAHoldIsATentativeEventTitledForTheBooker`: title `"Chat — Doc"`, status tentative, `PendingMail == MailHold`, token not on disk (grep).
- - `TestTwentyStrangersOneSlotOneWinner`: 20 goroutines call `HoldBooking` for the same slot; count `err == nil` (must be 1) and `ErrSlotTaken` (19). Run under `-race`.
- - `TestConfirmIsAConditionalUpdate`: first confirm `changed=true`; second `changed=false`; state confirmed; event confirmed with `Sequence == 1`; attendee recorded; `RemindersFor(host, event)` has one row (the default) when `host.DefaultReminderMin > 0` — set it via `SaveSettings` first; `PendingMail == MailConfirmed`.
- - `TestConfirmAfterExpiryChangesNothing`: `now + HoldTTL + 1m` → `changed=false`, state still `held` (the SWEEP moves it, not confirm).
- - `TestRescheduleMovesBumpsAndCanReturnToItsOwnSlot`: sequence goes 1→2; `Reschedules == 1`; moving back to the original slot succeeds (own event excluded); a sixth reschedule returns `ErrOverCap`.
- - `TestCancelIsConditional`: first `changed=true` with event cancelled and `Sequence` bumped and `ReminderQueue` rows for the event gone; second `changed=false`.
- - `TestHostDeleteCancelsTheBookingAndOwesMail`: `DeleteEvent(host, eventID)` → booking `cancelled`, `PendingMail == MailCancelled`.
- - `TestABookedEventCannotBecomeASeries`: `UpdateEvent` with `RRule: "FREQ=WEEKLY"` on the event → `ErrBookedEvent`.
- - `TestExpireHoldsFreesTheSlotAndNextHoldExpiryIsRight`.
- - `TestHoldCountsIncludeFailedRowsForAttempts`: three holds from one `IPFP`, `FailHold` on all three → `OpenIP == 0`, `AttemptsIP == 3`.
- - `TestOwedMailAndMarks`: after confirm, `OwedMail` returns the row; `MarkMailFailed` × `MaxReminderAttempts` clears the marker; `MailIsOwed` flips accordingly.
- - `TestForgetOldIPs`: age `created_at` via `DBForTest().Exec`; `ForgetOldIPs` nulls `ip_fp`.
- - `TestNextDueAtIsTheEarliestOfThree`: no reminders + a hold → hold expiry; owed mail → `<= now`.
-
-- [ ] **Step 2: Run; confirm failure.**
-- [ ] **Step 3: Implement.**
-
-`HoldBooking`:
+- [ ] **Step 1: Tests** — `booking_flow_test.go` (helpers `hostWithPage`, `slotsFor`) and `booking_mail_test.go`:
+ - 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`.
+ - 20 goroutines, one slot, one winner (`-race`).
+ - confirm: first `changed`; second not; event confirmed `Sequence == 1`; attendee; default reminder when host has one; one `MailConfirmed/ToHost` row at revision 1.
+ - confirm after `hold_expires_at` → no change; confirm when `now >= event.StartUTC` → no change (age the event via `DBForTest`).
+ - 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`.
+ - cancel by booker: `changed`; event cancelled, sequence bumped; `ReminderQueue` rows gone; unsent `MailRescheduled` rows deleted; two `MailCancelled` rows; `CancelledBy == booker`; second → no change.
+ - 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`.
+ - `UpdateEvent` with `RRule` on a booked event → `ErrBookedEvent`.
+ - 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.
+ - sweep: `ExpireHolds` → expired, event soft-deleted, queue rows gone, slot free; `NextHoldExpiry`; `ForgetOldIPs` at `ip_forget_at`; `NextIPForget`.
+ - `HoldCountsFor`: three holds one IP → `OpenIP 3, AttemptsIP 3`; expire → `OpenIP 0, AttemptsIP 3`.
+ - `NextDueAt` returns each of the four sources when it is earliest.
+ - reminder claim path: a queue row whose event is `cancelled` is dropped (`ClaimDueReminder` returns `ok=false` and deletes it).
+- [ ] **Step 2: Run; failure.**
+- [ ] **Step 3: Implement.** Sketches:
+
```go
-s.holdMu.Lock(); defer s.holdMu.Unlock()
-counts, err := s.HoldCountsFor(ctx, in.Page.ID, in.IPFP, addrFP, now)
-if counts.OpenIP >= 5 || counts.OpenPage >= 20 || counts.OpenAddress >= 2 || counts.AttemptsIP >= 20 || counts.AttemptsAddress >= 6 { return "", Booking{}, ErrOverCap }
-day := time.Unix(in.StartUTC, 0).In(hostLoc); dayStart := midnight(day); slots, err := s.FreeSlots(ctx, in.Page, in.Host, now, dayStart, dayStart.AddDate(0,0,1), 0)
-if !containsStart(slots, in.StartUTC) { return "", Booking{}, ErrSlotTaken }
-// then the transaction: buildEvent(...StatusTentative...) + tx.Create(&e); token; seal; tx.Create(&b)
+func (s *Store) HoldBooking(ctx context.Context, in HoldInput, now time.Time) (Booking, error) {
+ s.holdMu.Lock()
+ defer s.holdMu.Unlock()
+ addrFP, _ := s.seal.Fingerprint(seal.PurposeAttendeeLookup, normalizeAddress(in.Address))
+ c, err := s.HoldCountsFor(ctx, in.Page.ID, in.IPFP, addrFP, now)
+ if err != nil { return Booking{}, err }
+ if c.OpenIP >= 5 || c.OpenPage >= 20 || c.OpenAddress >= 2 || c.AttemptsIP >= 20 || c.AttemptsAddress >= 6 { return Booking{}, ErrOverCap }
+ loc, _ := Zone(in.Host.Zone)
+ d := time.Unix(in.StartUTC, 0).In(loc)
+ day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
+ slots, err := s.FreeSlots(ctx, in.Page, in.Host, now, day, day.AddDate(0, 0, 1), 0)
+ if err != nil { return Booking{}, err }
+ if !hasStart(slots, in.StartUTC) { return Booking{}, ErrSlotTaken }
+ token := newToken() // 32 bytes hex, as MintFeed
+ var b Booking
+ err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
+ calID, err := s.defaultCalendarIDTx(tx, in.Host.ID)
+ e, err := s.buildEvent(in.Host.ID, EventInput{CalendarID: calID, Title: in.Page.Title + " — " + in.Name, Notes: in.Note,
+ StartLocal: FormatLocal(time.Unix(in.StartUTC,0).In(loc), false), EndLocal: FormatLocal(time.Unix(in.EndUTC,0).In(loc), false),
+ TZ: in.Host.Zone, Kind: KindMeeting, Status: StatusTentative})
+ if err := tx.Create(&e).Error; err != nil { return err }
+ expires := now.Add(HoldTTL).Unix(); if in.StartUTC < expires { expires = in.StartUTC }
+ 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(), ...}
+ if err := tx.Create(&b).Error; err != nil { return err }
+ return queueBookingMail(tx, b.ID, MailHold, []string{ToBooker}, 0, now)
+ })
+ return b, err
+}
```
-`ConfirmBooking`: one transaction — `res := tx.Model(&Booking{}).Where("id = ? AND state = ? AND hold_expires_at > ?", id, BookingHeld, now.Unix()).Updates(map[string]any{"state": BookingConfirmed, "confirmed_at": now.Unix(), "pending_mail": MailConfirmed, "updated_at": now})`; `if res.RowsAffected == 0 { return false, nil }`; then event `status`, `sequence = sequence + 1` (use `gorm.Expr("sequence + 1")`), `updated_at`; `SetAttendees`; `EnsureDefaultReminder(ctx, host, event)` — NOTE it uses `s.g`, not `tx`; either make it accept a `*gorm.DB` or call it right after the transaction commits and accept the tiny window (say which in a comment; the reminder is the host's convenience, not the booking's correctness).
+`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)`.
-`RescheduleBooking`: `holdMu`; read booking (must be `confirmed`, `Reschedules < MaxReschedules` else `ErrOverCap`); `FreeSlots(..., excludeEventID = b.EventID)`; transaction: event `start_local`, `end_local`, `start_utc`, `end_utc`, `sequence+1`, `updated_at`; booking conditional update `WHERE id=? AND state='confirmed' AND reschedules = ?` setting `reschedules+1`, `pending_mail`; then `RescheduleReminder` for each `RemindersFor(host, eventID)` (again outside `tx` if needed).
+`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)`.
-`CancelBooking`: conditional `WHERE id=? AND state='confirmed'` → `cancelled`, `cancelled_at`, `pending_mail`; event `status=cancelled`, `sequence+1`; `DELETE FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?)`.
+`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`).
-`store_event.go`: in `UpdateEvent`, after validation: `if in.RRule != "" { if _, booked := s.bookingIDForEvent(ctx, eventID); booked { return ErrBookedEvent } }`. In `DeleteEvent`'s transaction: `tx.Model(&Booking{}).Where("event_id = ? AND state = ?", eventID, BookingConfirmed).Updates(map[string]any{"state": BookingCancelled, "cancelled_at": now.Unix(), "pending_mail": MailCancelled, "updated_at": now})`; also `held` → `expired` (a deleted hold is a dead hold).
+`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")`.
-`NextDueAt`: reminder due → `min` with `NextHoldExpiry` → if `MailIsOwed` return `time.Now()`.
+`store_drain.go` `ClaimDueReminder`: after loading `e`, `if e.Status == StatusCancelled { delete queue row; return DueReminder{}, false, nil }`.
-- [ ] **Step 4: `go test -race ./internal/calendartest/ -run 'Hold|Confirm|Reschedule|Cancel|Booked|Expire|Owed|Forget|NextDueAt' -count=1`; pass. `make ci`; commit** `booking: held → confirmed → cancelled as conditional updates, and mail as an outbox the drain owns`.
+`NextDueAt`: min over four non-zero times.
+
+- [ ] **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`.
---
### Task 5: Host settings UI
-Spec §2.1, §3.4, §7.3.
-
-**Files:** create `handlers_booking_settings.go` (list, publish, parse), `handlers_booking_form.go` (new/edit/create/update), `templates/booking_pages.html`, `booking_page_form.html`, `static/booking.js`; modify `settings.html`, `handlers_misc.go` (`saveSettings` + `settings` read `handle`), `routes.go`, `render.go` (`pageNames` + ParseFS), `layout.html` (`booking.js` after `quickadd.js`), `calendar.css`; test `booking_settings_test.go`.
-
-**Routes (signed-in group):**
-```
-GET /settings/booking list
-GET /settings/booking/new form
-POST /settings/booking create → 303 /settings/booking?saved=1
-GET /settings/booking/{id} edit form; 404 if not mine
-POST /settings/booking/{id} update → 303
-POST /settings/booking/{id}/publish publish=1|0 → 303
-POST /settings/booking/parse fragment
-```
-
-- [ ] **Step 1: Tests** — `booking_settings_test.go`:
- - `TestAHostMakesAPageAndSeesItsPublicURLOnlyOncePublished`: save settings with `handle=paul`; create page; list shows title and windows but says "Not published" and shows no `/book/` URL; POST publish=1; list shows `/book/paul/<code>` and the publish sentence ("Anyone with this link can see when you are free"). Extract the code with `regexp.MustCompile(`/book/paul/([a-z0-9]{8})`)`.
- - `TestAPageWithAnUnreadableSentenceIsNotSaved`: 422, quotes the clause, list has no such page.
- - `TestThePaneFragmentDescribesTheWindows`: `fri 9-5` → `Friday 09:00–17:00`, no `<html`.
- - `TestAnotherPersonsBookingPageIs404`: GET and POST `/settings/booking/{id}` as another person → 404; POST publish → 404.
- - `TestATakenHandleIsRefusedOnSettings`: second person posts `handle=paul` → 422 containing "taken".
-
-- [ ] **Step 2: Run; confirm.**
-- [ ] **Step 3: Implement.** `saveSettings`: after `SaveSettings` succeeds, `SaveHandle`; `ErrHandleTaken` → 422 with "That handle is taken."; `ValidationError` → its message. `settings.html`: a `field-text` for `handle` (value `{{with .Data.Person.Handle}}{{.}}{{end}}`), hint "Lowercase letters, digits and dashes. Changing it changes every booking link you have shared.", and a link "Booking pages →" to `/settings/booking`.
-
-`bookingPageForm` struct: `ID int64; Title, Description, Availability, DurationMin, BufferMin, NoticeMin, HorizonDays string; Pane BookingPaneView; Error string`. `readBookingPageForm(r) (BookingPageInput, bookingPageForm)` with defaults `30/10/120/30` when blank. Create/update: `ws, problems := ParseAvailability(in.Availability)`; problems → 422 with `Pane{Lines, Problems}`; `ValidationError` → 422 with `Error`; else store → 303.
-
-`booking_pages.html`: page-header "Booking pages" with a "New page" `rst-btn="primary"` link; when the person has no handle, a `callout` (warning): "Choose a handle in Settings before publishing — it is the first part of every booking link." Each page: title, duration, `DescribeWindows` lines, state badge (Published / Not published), the URL when published (`h.origin + "/book/" + handle + "/" + code`, as a `<code>` and a copyable link), Edit link, and a publish form whose button reads "Publish" or "Unpublish" with the §7.3 sentence above it as `<p cal-book-caveat>`.
-
-`booking_page_form.html`: `field-text` title, `field-textarea` description, `field-textarea` availability with `cal-book-text` (wrap in `<div cal-book-form>` so the JS can find root), then `<div id="cal-book-pane" cal-book-pane aria-live="polite">{{template "bookingpane" .Data.Pane}}</div>`, `field-select` duration (5,10,15,20,30,45,60,90,120), `field-text` buffer/notice/horizon with `inputmode=numeric`, `form-foot` Save / Cancel → `/settings/booking`.
+Spec §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`.
-`booking.js` (clone `quickadd.js`; < 120 lines): `setupPane(root)` for `[cal-book-form]` → `/settings/booking/parse`; `setupZone()` — if `[cal-book-zone]` exists and has no `data-chosen`, set its value to `Intl.DateTimeFormat().resolvedOptions().timeZone` when that value is one of its options.
+**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`.
-CSS: `[cal-book-pane]`, `[cal-book-lines]`, `[cal-book-problems]`, `[cal-book-caveat]`, `[cal-book-slots]`, `[cal-book-slot]` using existing `--rst-*` tokens already referenced in `calendar.css` (the guard test fails on a token that is not declared).
-
-- [ ] **Step 4: Run; pass. `make ci`; commit** `booking: the host's side — a handle, pages, a pane, and a Publish button that says what it does`.
+- [ ] **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".
+- [ ] **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`).
+- [ ] **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`.
---
-### Task 6: The public flow, mail and ICS
-
-Spec §5, §6, §7.1 (429), §2.5.
+### Task 6: The public flow, mail, ICS, the timed sender, the token bucket
-**Files:** create `handlers_booking_public.go` (page GET/POST), `handlers_booking_manage.go` (manage GET, confirm, reschedule, cancel), `handlers_booking_util.go` (`clientIP`, `notFoundLikeTheRouter`, `publicHeaders`, zone pick), `booking_ics.go`, `booking_mail.go`, `templates/mail/{hold,confirmed_host,rescheduled,cancelled}.txt`, `templates/book_page.html`, `book_hold.html`, `book_sent.html`, `book_manage.html`, `book_gone.html`; modify `routes.go`, `render.go`, `calendar.css`; test `booking_http_test.go`, `booking_abuse_test.go`.
+Spec §4.2, §5, §6, §7.1–7.2, §2.7.
-**Routes (chi router, outside the session group, registered BEFORE `/book/{handle}/{code}`):**
+**Routes (chi router, outside the session group, `/book/m/...` registered BEFORE `/book/{handle}/{code}`):**
```
-GET /book/m/{token} state page (held: confirm button; confirmed: manage)
+GET /book/m/{token} held: confirm button; confirmed: manage; else: gone
POST /book/m/{token}/confirm
GET /book/m/{token}/calendar.ics
-GET /book/m/{token}/reschedule ?day=&zone=
-POST /book/m/{token}/reschedule start, zone
+GET /book/m/{token}/reschedule ?day=&zone= (+ hidden rev)
+POST /book/m/{token}/reschedule start, zone, rev
POST /book/m/{token}/cancel
GET /book/{handle}/{code} ?day=&zone=
POST /book/{handle}/{code} start, zone [, name, address, note]
```
-**Utilities:**
+**Produces:**
```go
-func clientIP(r *http.Request) string // last XFF element, else RemoteAddr host; net.ParseIP(...).String() or "unknown"
-func notFoundLikeTheRouter(w http.ResponseWriter, r *http.Request) // ErrorPage(w, r, 404, "") — confirm this is what chi's NotFound is wired to in routes.go/main.go; if the router's 404 is plain http.NotFound, use that instead. The test compares bodies.
-func publicHeaders(w http.ResponseWriter) // Cache-Control: no-store; Referrer-Policy: no-referrer
-func pickZone(r *http.Request, fallback string) (zone string, chosen bool) // IsCommonZone(r.FormValue("zone")) else fallback
+// handlers_booking_util.go
+func clientIP(r *http.Request) string
+func (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
+func notFoundLikeTheRouter(w, r)
+func zoneChoices(host Person) []string // CommonZones ∪ {host.Zone}
+func pickZone(r *http.Request, host Person) (zone string, chosen bool)
+type bucket struct{ mu sync.Mutex; tokens map[string]*tb } // 60 tokens, refill 1/s, prune idle keys past 5 min
+
+// mail_timed.go
+var ErrMailTimeout = errors.New("calendar: mail send timed out")
+type timedSender struct{ next mail.Sender; limit time.Duration }
+func (t timedSender) Send(ctx, to, subject, body string) error // goroutine + select on time.After(limit); the request WAITS here
+
+// booking_mail.go
+func (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
+func (h *handlers) trySendNow(ctx context.Context, bookingID int64) // claims and sends every unsent row for the booking, once; errors only logged (scrubbed)
+
+// booking_ics.go
+func WriteBookingICS(v BookingView) string // METHOD on VCALENDAR; one VEVENT; allowlisted fields only; no URL:
```
-**Mail:** `//go:embed templates/mail/*.txt` → `text/template`. `func (h *handlers) sendBookingMail(ctx, v BookingView, token string) error` switches on `v.PendingMail`; returns the first error. Each template receives `struct{ Title, Description, HostName, HostAddress, Name, Address, Note, WhenBooker, WhenHost, ExpiryBooker, ManageURL, EventURL string; Who string }`. `hold.txt` body per spec §6 (the "Somebody — we hope you —" wording). `ManageURL` is only known at hold time (the token is plaintext once); for later mails the booker gets the manage URL ONLY if the handler has the token in hand (confirm/reschedule/cancel handlers do, from the path); the DRAIN retry does not, so the rescheduled/cancelled templates say "the link in your first mail" when `ManageURL` is empty. Wrap `Send` in `context.WithTimeout(ctx, 20*time.Second)` with the comment the spec asks for.
-
-**ICS:** `func WriteBookingICS(v BookingView) string` — `BEGIN:VCALENDAR / VERSION / PRODID / CALSCALE / METHOD:REQUEST|CANCEL` then one VEVENT: `UID` (event's opened UID: extend `BookingByToken` to open `UIDSealed` with `PurposeEventUID`), `DTSTAMP`, `SEQUENCE:{v.Event.Sequence}`, `DTSTART;TZID=host zone`, `DTEND`, `SUMMARY:{page title}`, `DESCRIPTION:{page description}` (only if non-empty), `ORGANIZER;CN={HostName}:mailto:{host address}`, `ATTENDEE;CN={Name};PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:{booker}`, `STATUS:CONFIRMED|CANCELLED`. **No `URL:`. No event notes. No location. No other attendees.** Reuse `line`, `escapeText`, `icsDateTime`, `utcStamp` from `ics_export.go`.
-
-- [ ] **Step 1: Tests** — `booking_http_test.go`. Helpers: `publishedPage(t) (*calendar.App, *pickyMailer, string /*path*/, calendar.Person)`; `strangerGet/strangerPost` (RequestForTest with nil session; for POSTs also set `req.RemoteAddr` when a test needs distinct IPs); `reStart = regexp.MustCompile(`name="start" value="(\d+)"`)`; `reManage = regexp.MustCompile(`/book/m/([0-9a-f]{64})`)`; extend `pickyMailer` so `refuse` also matches `to`.
-
- - `TestAStrangerHoldsAndTheLinkOnlyOffersAButton`: GET page with `?day=` → slot forms; POST slot (no name) → 200 with details form carrying `start` hidden; POST with name/address/note → 200 "Check your mail" echoing the address; exactly one mail, to the booker, containing `/book/m/`; GET the link → 200 with `Confirm this booking` and a form posting to `…/confirm`; **state still held, mails still 1**; POST confirm → 200 manage with "Reschedule"; mails now 2, the second to the host, containing "Doc"; POST confirm again → mails still 2.
- - `TestTheICSIsARequestWithNoManagementLink`: after confirm, GET `…/calendar.ics`: `text/calendar`, `METHOD:REQUEST`, `SUMMARY:Quick chat`, `ATTENDEE;CN=Doc;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:doc@tito.io`, `ORGANIZER`, `mailto:host@example.com`, `SEQUENCE:1`, and `!strings.Contains(body, "URL:")`. Parse it with the app's importer (find the parse entry point in `ics_import.go`/`ics_parse.go` — `ParseICS` or similar — and assert one event).
- - `TestTheHostsLaterEditsNeverReachTheBooker`: host `UpdateEvent` adding `Notes: "private"`, `Location: "Room 9"`, then `SetAttendees(event, [doc, "boss@example.com"])`; GET manage, GET ICS, and the cancelled mail body after a booker cancel must contain none of `private`, `Room 9`, `boss@example.com`.
- - `TestTheSameSlotCannotBeHeldTwice` → 409 "just gone".
- - `TestAWrongTokenIsTheSame404AsAnUnknownRoute`: bodies byte-identical.
- - `TestAnExpiredHoldSaysSoOnlyToTheRightToken`: age `hold_expires_at`; GET link → 200 containing "expired"; GET wrong token → 404.
- - `TestRescheduleAndCancelTellBothParties`: reschedule → 2 mails, ICS `SEQUENCE:2`; cancel → 2 more, `METHOD:CANCEL`; second cancel → no more mail.
- - `TestHostDeletingTheEventTellsTheBooker`: host `POST /events/{id}/delete` (or `DeleteEvent`) → `tick` → the booker got a cancelled mail; GET manage says cancelled.
- - `TestAHoldWhoseMailIsRefusedIsFailedAndFreesTheSlot`: `mailer.refuse = "bounce@example.com"` → 422 "could not send"; the slot is offered again; the row exists with state `failed` (query `DBForTest`).
- - `TestPublicResponsesAreNoStoreNoReferrer`.
-
- `booking_abuse_test.go`:
- - `TestTheSixthOpenHoldFromOneIPIs429` (six different slots, same `RemoteAddr`, distinct addresses).
- - `TestTheThirdOpenHoldForOneAddressIs429`.
- - `TestAttemptsCountFailedRows`: refuse the address; 6 failed holds from one address → the 7th is 429 even though none is open.
- - `TestABodyOverTheLimitIs422` (a 20 KiB note).
- - `TestAZoneOutsideCommonZonesFallsBackToTheHosts` (`?zone=Nowhere/Town` renders the host's zone selected).
-
-- [ ] **Step 2: Run; confirm failure.**
-- [ ] **Step 3: Implement.** Handler shapes:
-
-`bookPage` GET: `PublicBookingPage` → 404; `publicHeaders`; `zone, chosen := pickZone(r, host.Zone)`; `FreeSlots` over `[now, now+horizon]` once; group by `time.Unix(s.StartUTC,0).In(bookerLoc).Format(LayoutDate)`; if `?day=` matches a group, render that day's slots as forms; else render the day list as links. Template data: `Page, HostName, Zone, Zones []string (CommonZones), Chosen bool, Days []struct{Date, Label string; N int}, Day string, Slots []struct{StartUTC int64; Label string}, Picked *struct{StartUTC int64; Label string}, Form struct{Name, Address, Note, Error string}`.
-
-`bookPost` POST: `r.Body = http.MaxBytesReader(w, r.Body, 16<<10)`; `ParseForm` error → 422; `start` int; if `name == ""` → re-render the page with `Picked` set (verify `start` is currently free; if not → 409 "just gone" with the day's slots). Else validate name/address/note; `HoldBooking(HoldInput{..., IPFP: fp(clientIP(r))}, now)`: `ErrOverCap` → 429 `book_gone` with the §7 sentence; `ErrSlotTaken` → 409; else `sendBookingMail(hold)`: error → `FailHold` + 422 "We could not send to that address"; success → `MarkMailSent`; render `book_sent` (address, expiry in booker zone).
-
-`bookManage` GET: `BookingByToken` → 404; `publicHeaders`; switch state / expiry / `Event.Status == cancelled || Event.DeletedAt != nil` → `book_hold` (button) | `book_gone` (expired/cancelled/failed) | `book_manage`.
-
-`bookConfirm` POST: `ConfirmBooking` → `changed` → `sendBookingMail(confirmed)` → `MarkMailSent`/`MarkMailFailed`; then render as `bookManage` does. Zero rows → just render.
-
-`bookReschedule` GET/POST: picker with `excludeEventID`; POST → `RescheduleBooking` (`ErrSlotTaken` 409, `ErrOverCap` 429) → mail both → render manage.
-
-`bookCancel` POST: `CancelBooking` → if changed, mail both → render.
-
-`bookICS`: state `confirmed` or `cancelled` only, else 404; headers `text/calendar; charset=utf-8`, `Content-Disposition: attachment; filename="booking.ics"`, plus `publicHeaders`.
-
-Templates render through `render(w, name, h.page(r, title, data))`; the layout hides the nav for a stranger. Add the five names to `pageNames`.
-
-- [ ] **Step 4: Run; pass. `make ci`; commit** `booking: a stranger holds a slot, a button confirms it, and the ICS is one click away`.
+`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`).
+
+- [ ] **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}`):
+ - 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.
+ - confirm after `event.start_utc` (age via SQL) → page says expired/too late, no change.
+ - 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.
+ - host edits never reach the booker (note, location, second attendee → absent from manage, ICS, cancelled mail).
+ - same slot twice → 409; wrong token → byte-identical 404; expired says expired.
+ - 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.
+ - host cancels via edit → booker mailed, page says cancelled by host; host deletes → same, ICS sequence higher.
+ - 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`.
+ - `no-store` and `no-referrer` on every public response.
+ `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`).
+ `timedSender`: a `stallingMailer` that blocks on a channel → `Send` returns `ErrMailTimeout` within `limit+1s` (set `limit` small via a constructor).
+- [ ] **Step 2: Run; failure.**
+- [ ] **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).
+- [ ] **Step 4: Pass; `make ci`; commit** `booking: a stranger holds a slot, a button confirms it, and the ICS is one click away`.
---
-### Task 7: Sweep in the drain, leak test, log test, browser passes
+### Task 7: Drain ordering, leak and log tests, browser passes
Spec §8, §9.
-**Files:** modify `handlers_jobs.go` (`drain` and `DrainOnRequest`), `events_test.go`; create `booking_sweep_test.go`, `test/browser/booking.mjs`; modify `playwright_test.go`, `Makefile`, `package.json`.
-
-- [ ] **Step 1: Tests** — `booking_sweep_test.go`:
- - `TestTheTickExpiresHoldsFreesTheSlotAndWakesForThem`: hold; age `hold_expires_at`; `app.NextDue().Before(now)`; `tick` 204; slot offered again; the held event is gone from `/events/{id}` (404 as the host).
- - `TestTheTickRetriesOwedMail`: confirm with the host address refused → `pending_mail='confirmed'`; un-refuse; `tick` → host mail delivered; marker cleared; `NextDue` no longer `<= now`.
- - `TestAPageViewAlsoSweeps`: a signed-in GET `/today` after an expiry (DrainOnRequest) expires it.
- - `TestIPFingerprintsAreForgottenAfterADay`.
- - `TestNoAddressOrTokenReachesTheLog`: build the app with `Logger: slog.New(slog.NewTextHandler(&buf, nil))` (add a `newAppWithMailerAndLogger` helper); refuse the hold mail; assert `!bytes.Contains(buf, address)` and no 64-hex run.
- - Extend `TestTheDatabaseNeverHoldsAPlaintextTitle` with a booking fixture: page title `"Interrogation about the acquisition"`, description `"Bring nothing"`, availability `"mondays 12-3"`, booker `"Dermot Lacey"`, `"dermot.lacey@elsewhere.example"`, note `"The folder"`, and the token; checkpoint; grep all seven.
-
-- [ ] **Step 2: Implement the drain steps** at the top of `drain` AND in `DrainOnRequest` (bounded 200/20/500), each logging only counts with a stage. Mail retries call `sendBookingMail(v, "")` — no token — and `MarkMailSent`/`MarkMailFailed`.
-
-- [ ] **Step 3: Browser** — `test/browser/booking.mjs` taking `origin cookieName cookieValue publicPath`. Pass A (JS off, no cookie): open `publicPath`, click the first day link, click the first slot button, fill `name`/`address`, submit, assert "Check your mail". Then the Go side reads `app.LastMailForTest()`, extracts the link, and node pass B (JS off) opens it, asserts "Confirm this booking", presses the button, asserts "Reschedule". Pass C (JS on, signed-in cookie): `/settings/booking/new`, type `tues 12-2`, wait for `[cal-book-pane]` to contain `Tuesday 12:00–14:00`. Because pass B needs the mail from pass A, split the node script into two invocations (`booking.mjs hold …` and `booking.mjs confirm … <link>`) driven from a new `TestBookingInARealBrowser` in `playwright_test.go`; the Go test creates the host/handle/page/publish through the store first. Update `Makefile` and `package.json` to `-run 'InARealBrowser'`.
-
-- [ ] **Step 4: `make ci` and `make browser` green; commit** `booking: holds expire on the tick, owed mail is retried, and a stranger with no script can book and confirm`.
+- [ ] **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).
+- [ ] **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.
+- [ ] **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`.
+- [ ] **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`.
---
### Task 8: AGENTS.md
-Spec §11, verbatim. Rows B1, B2, B4, B5, B8, B11, B12, B13 in Settled decisions ("Booking spec §1, 2026-09-07"); the Frontend booker exception; the two Data bullets; rename Reminders → Ticks with the `NextDue` bullet; leak-list item 5; the Time bullet on `Zone()`. Commit `docs: the rules a booking page adds`.
+Spec §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`.
---
## Self-review
-- **Spec coverage:** §2 → T1 (+ `ip_fp`, `pending_mail`, `reschedules` columns); §2.5 → T6 `clientIP`; §3 → T2, T5; §4 → T3 (round-trip rule, all-day via DayBounds, lapsed holds excluded, ±1 day envelope, per-candidate filters); §5.1–5.2 → T6 (two-step form, limits, caps 429, 409); §5.3–5.4 → T6 (GET shows button; POST confirms; idempotent by RowsAffected); §5.5 → T4 (`UpdateEvent`/`DeleteEvent`) + T6 (allowlist + test); §5.6–5.7 → T4, T6; §6 → T6 + T7 (retry); §7 → T4 counts, T6 429s, T7 forget; §8 → T7; §9 → each task's Step 1; §11 → T8.
-- **Type consistency:** `Window` in `models_booking.go`; `BookingPageView.Windows []Window`; `HoldInput.Page BookingPageView`; `BookingView.Event EventView` and public code reads only `Status/StartUTC/EndUTC/Sequence/DeletedAt/UIDSealedOpen` from it; `MailHold…MailCancelled` constants match `pending_mail` values in the spec; `HoldCounts` field names match the five caps.
-- **Placeholders:** the `"23:59"` line in Task 2's table is flagged for removal in the note beneath it; the DST expectations are pinned by running once and recording, which is the repository's stated convention for DST numbers.
+- **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.
+- **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`.
+- **Placeholders:** none. DST expectations are pinned by observing once and recording, the repository's convention.
diff --git a/docs/superpowers/specs/2026-09-07-booking-design.md b/docs/superpowers/specs/2026-09-07-booking-design.md
index 90456ce..a874e8d 100644
--- a/docs/superpowers/specs/2026-09-07-booking-design.md
+++ b/docs/superpowers/specs/2026-09-07-booking-design.md
@@ -4,8 +4,10 @@
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
-3.** Reviewed twice by `gpt-6-astra` through `codex-cli 0.153.0`; both
-rounds returned REWRITE. §12 records every finding and what changed.
+4.** Reviewed three times by GPT-6 (`gpt-6-astra` on the one run that
+exposed its identifier) through `codex-cli 0.153.0`; all three returned
+REWRITE. §12 records every finding and disposition, including the two
+this revision scopes rather than solves.
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
@@ -39,23 +41,27 @@ first — and is 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 | 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. |
-| 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). |
+| B1 | **A booking is confirmed by a button on the page the mailed link opens** — a same-origin POST — never on submit and never on GET | Instant confirmation; confirming on the GET | A GET that confirms is confirmed by every link-scanning mail gateway (r1 f2). The mailed link is a request; the button is the consent. |
+| B2 | **Everything the host can see is busy** | Personal only | Nobody gets booked over the team standup. |
+| B3 | **Pages are per person, any number** | One page | "15 min chat" and "60 min call" differ. |
+| B4 | **Public identifier: chosen plaintext `handle` + GENERATED page code** | A chosen slug | A slug is prose joined to events (r1 f1). |
+| B5 | **Booking mail is the ONE outbound to a non-team address, and only to an address the recipient typed in order to receive it** | Emailing quick-add attendees | The booker asked for the mail. |
+| B6 | **The ICS is a download at a tokenised URL, not an attachment, and carries NO management link** | Attachment; `URL:` | `mail.Sender` is plain text; a capability inside an ICS syncs everywhere (r1 f6). |
+| B7 | **ONE availability parser, in Go** | JS parser | The standing rule. |
+| B8 | **A held slot is a tentative EVENT** | A holds table | The range query already subtracts events. |
+| B9 | **Slots are computed in the HOST's zone and rendered in the booker's** | Booker's zone | The windows are the host's hours. |
+| B10 | **Unknown token → 404**, never 403 or "expired" | Distinguishing | No enumeration. |
+| B11 | **The EVENT's `sequence` is the one revision counter**, bumped as `sequence = sequence + 1`, and every booking-side write names the revision it expects | A booking sequence; stale `+1` | Drift (r1 f11); lost bumps (r2 A11); replayed forms (r2 A10). |
+| B12 | **Hold and reschedule serialise on a process-wide mutex** | The write transaction alone | SQLite begins deferred (r1 f9). |
+| B13 | **Mail is a QUEUE TABLE, one row per (booking, kind, recipient, revision), claimed with an identity and a lease, acknowledged conditionally on that identity** | A marker column; anonymous claims | One marker loses a cancellation mid-send (r2 B2); an old worker must not acknowledge a newer claim (r3 f6). |
+| B14 | **The token is 32 bytes from `crypto/rand`, hex-encoded, stored SEALED as well as hashed** | Hash only; derived tokens | The queue must rebuild the link (r2 B1); a derived token is a guessable one (r3 f11). |
+| B15 | **A hold expires at the EARLIER of thirty minutes and the meeting's start; confirmation requires `now < start`** | Thirty minutes flat | A five-minute meeting confirmed after it ended (r2 B5). |
+| B16 | **No sender error is "definite."** Nothing is freed because a send failed | Marking holds failed | `smtp.SendMail` can fail after acceptance (r2 A12). |
+| B17 | **A slot is the same length on the wall clock and the elapsed clock, or it is not offered** | Round-tripping the start only | The fold makes a two-hour hour (r2 A13). |
+| B18 | **A booked event carries INVARIANTS that every host edit must keep**: timed, 5–480 minutes, wall = elapsed, status ∈ {confirmed, cancelled}, no rule, and never revived from a terminal booking state | Letting the host edit it like any event | A zero-length edit makes slot generation loop forever inside the mutex (r3 f1); a stale form revives a cancelled booking (r3 f5). |
+| B19 | **Mail says the CURRENT state**, worded for it, and a transition deletes every unsent row the new state makes false | Historical snapshots | Snapshots need a sealed body per row and per-booking serialised delivery (r3 f4); current-state wording needs neither and is what a reader wants to know. What has already reached the sender cannot be recalled, and the spec says so. |
+| B20 | **The booker sees the page's title and description AS THEY WERE at hold time**, snapshotted sealed onto the booking | Live page text | Live text changes the ICS without changing its `SEQUENCE` (r3 f10). |
+| B21 | **Reschedule writes `tz` = the host's current zone atomically with both local strings and both UTC columns** | Leaving `tz` as it was | A host who moved zones would get Tokyo strings on a Dublin record (r3 f2). |
---
@@ -78,17 +84,16 @@ availability_text_sealed, published_at (nullable), created_at, updated_at
UNIQUE (person_id, code)
```
-- `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.
+- `code`: eight of `[a-z0-9]`, generated, immutable, plaintext. Not a
+ secret — it is in a shared URL — and distinct from the management
+ token (§2.4).
+- `title_sealed` ≤ **100 bytes**; `description_sealed` ≤ 1000 bytes.
+ Byte limits, because the event validator's are bytes (r2 B6).
- `duration_min` 5–480; `buffer_min` 0–120 (default 10); `notice_min`
- 0–10080 (default 120), **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.
+ 0–10080 (default 120) with a **floor of 10** at slot time (B15);
+ `horizon_days` 1–90 (default 30).
- `published_at`: NULL is unpublished → 404. Publishing is explicit
- (§7.3).
+ (§7.4).
### 2.3 `booking_windows`
@@ -97,13 +102,13 @@ id, page_id (idx), weekday (0=Sunday..6), start_min, end_min
```
Plaintext (shape-of-time). `0 <= start_min < end_min <= 1440`.
-Rewritten wholesale on save, **merged** first: overlapping or touching
-windows on one weekday become one.
+Rewritten wholesale on save, **merged** first.
### 2.4 `bookings`
```
id, page_id (idx), event_id (idx), token_hash UNIQUE, token_sealed, state (idx),
+title_sealed, description_sealed,
booker_name_sealed, booker_address_sealed, booker_address_fp (idx),
note_sealed, booker_zone,
hold_expires_at (idx), confirmed_at, cancelled_at, cancelled_by,
@@ -111,59 +116,67 @@ ip_fp (idx, nullable), ip_forget_at (idx), reschedules,
created_at, updated_at
```
-- `token_hash`: `HashFeedToken(token)`, the lookup. `token_sealed`:
- the same token under `PurposeBookingToken` (B14), opened only to
- build a mail.
+- **Token (B14):** 32 bytes from `crypto/rand`, `hex.EncodeToString`
+ (64 characters, URL-safe as is). `token_hash = HashFeedToken(token)`
+ is the lookup; `token_sealed` is the token under
+ `PurposeBookingToken`, opened only to render a mail. A `UNIQUE`
+ collision on insert regenerates once and then errors.
- `state`: `held` → `confirmed` → `cancelled`, or `held` → `expired`.
- 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).
+- `title_sealed`, `description_sealed`: **the page's text at hold time**
+ (B20). Every public surface reads these, never the page's.
+- `booker_zone`: one of `CommonZones` or the host's own zone.
+- `cancelled_by`: `''`, `booker`, `host`.
+- `ip_fp`: `Fingerprint(PurposeBookingIP, ip)`; `ip_forget_at =
+ created_at + 24h`; nulled by the sweep at or after it; the earliest
+ outstanding `ip_forget_at` is a `NextDueAt` source.
- `reschedules`: capped at `MaxReschedules = 5`.
-### 2.5 `booking_mail`
+### 2.5 `booking_mails`
```
-id, booking_id (idx), kind, recipient, revision, due_at (idx),
-attempts, claimed_at, sent_at, created_at
+id, booking_id (idx), kind, recipient, revision,
+due_at (idx), attempts, claimed_at, claim_id, sent_at, created_at
UNIQUE (booking_id, kind, recipient, revision)
```
-- `kind`: `hold`, `confirmed`, `rescheduled`, `cancelled`.
- `recipient`: `booker` 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.
+- `kind` ∈ {`hold`, `confirmed`, `rescheduled`, `cancelled`};
+ `recipient` ∈ {`booker`, `host`}; `revision` = the event's
+ `sequence` when the row was made.
+- Rows are created inside the transition's transaction with `due_at =
+ now`. The handler that made them tries once, immediately (§6); the
+ drain owns retries.
+- **Claim (B13):** `UPDATE … SET claimed_at = now, claim_id = <random>
+ WHERE id = ? AND sent_at IS NULL AND due_at <= now AND (claimed_at IS
+ NULL OR claimed_at < now - claimLease)`; the claimer keeps
+ `claim_id`. **Acknowledge:** `SET sent_at = now WHERE id = ? AND
+ claim_id = ?`. **Fail:** `SET attempts = attempts + 1, due_at = now +
+ claimLease, claimed_at = NULL WHERE id = ? AND claim_id = ?` — the
+ retry time is EXPLICIT in `due_at`, so `NextDueAt` never advertises
+ work nobody may claim (r3 f6). At `MaxReminderAttempts`, `sent_at =
+ -1` (gave up) and a log line says `stage=booking.mail code=gave_up`.
+- **Supersession (B19):** when a transition makes a booking
+ `cancelled` or `expired`, every unsent row for that booking is
+ deleted in the same transaction and the `cancelled` rows are queued
+ (none for `expired`). When a reschedule succeeds, unsent
+ `rescheduled` rows are deleted before the new ones are queued. A
+ `confirmed` row survives a reschedule and its body says the current
+ time. A row that has already been claimed and handed to the sender
+ cannot be recalled; a booker may therefore receive "confirmed" a
+ minute before "cancelled" if both raced. Stated, not hidden.
+- No content is stored. Bodies render at send time from the booking
+ (snapshotted text, sealed booker fields, opened token), the event's
+ allowlisted columns (§5.5) and the booking's CURRENT state.
+
+### 2.6 Ownership
+
+Every settings read or write of a page is `WHERE id = ? AND person_id
+= ?` → `ErrNotFound` → 404. `PublicBookingPage(handle, code)` is the
+only unscoped reader and requires `published_at IS NOT NULL`.
+
+**Terms.** A booking's times and duration are the event's; a page edit
+never touches an existing event; a reschedule keeps the event's
+duration. A booking's title and description are its own snapshot
+(B20). The page's live text affects only future bookings.
### 2.7 The client IP
@@ -172,8 +185,7 @@ 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.
+`net.ParseIP(...).String()`; unparseable → `"unknown"`. Never logged.
---
@@ -183,7 +195,7 @@ literal `"unknown"`. Never logged.
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.
+clause's days; a first clause with none is a problem.
```
mondays between 12–3, tuesdays 12–2, 5-7
@@ -199,35 +211,32 @@ saturday 10:30–13:00; sunday 14-16
day` / `daily` / `all days`.
- **Time range**: two clocks joined by `-`, `–`, `—`, `to`, `until`,
`till`. A clock is `H`, `H:MM`, `Ham`, `H:MMpm`. **Raw components are
- validated first, with no clamping** (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.
+ validated first, with no clamping**: minute `0..59`; with a meridiem
+ the hour is `1..12`; without, `0..24`, and `24` only as `24:00` at
+ the END. **Meridiem:** an explicit `am`/`pm` on either end applies to
+ both unless the other says otherwise; with none, the working-day rule
+ applies to the HOUR COMPONENT regardless of minutes: 1–7 → afternoon
+ (`5-7` → 17:00–19:00; `12:30-2:30` → 12:30–14:30), 8–12 and 0, 13–24
+ as written. End not after start → problem, the range quoted.
- Filler dropped: `between`, `from`, `and`, `on`, `at`.
### 3.2 What it refuses
-Every unconsumed token is a problem, quoted. All problems are reported.
-The page cannot be saved while any exists.
+Every unconsumed token is a problem, quoted. All problems are
+reported. The page cannot be saved while any exists.
### 3.3 Where it lives
-`internal/calendar/avail.go` + `avail_windows.go`. `ParseAvailability(text)
-(windows []Window, problems []string)`, pure, windows merged and sorted.
+`internal/calendar/avail.go` + `avail_windows.go`. `ParseAvailability
+(text) (windows []Window, problems []string)`, pure, merged, sorted.
Shares `consumed`, `weekdayAlt`, `weekdays`, `rangeSep` with quick add;
does NOT call `clock`/`applyMeridiem`.
### 3.4 The pane
-`POST /settings/booking/parse` → fragment: 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.
+`POST /settings/booking/parse` → fragment: windows in words plus every
+problem. `booking.js` is a debounced fetch cloned from `quickadd.js`.
+Scriptless: a save with problems re-renders at 422 with the fragment.
---
@@ -236,224 +245,287 @@ the same fragment inline.
`ComputeSlots(rule, busy, now, from, to)` is pure (`slots.go`);
`FreeSlots` fetches busy and calls it (`store_slots.go`).
+0. **Preconditions.** `ComputeSlots` returns an error, never a list,
+ when `DurationMin < 1` or `> 480`, when `Zone()` fails, or when a
+ window has `EndMin <= StartMin` (r3 f1). Callers surface the error;
+ nothing loops.
1. **Bounds.** `earliest = now + max(notice_min, 10m)`; `latest =
- now.In(hostLoc).AddDate(0,0,horizon_days)`. A candidate is offered
- 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
+ now.In(hostLoc).AddDate(0,0,horizon_days)`. Offered iff `start >=
+ earliest && start >= from && start < to && end <= latest`. `to`
+ bounds the START. Every inequality is per candidate.
+2. **Days.** Host-zone calendar days from `from` to `to`, by
`AddDate(0,0,1)` on a local midnight.
3. **Candidates.** For each window that weekday, `m` from `start_min`
by `duration_min` while `m + duration_min <= end_min`: `start =
time.Date(y, mo, d, 0, m, 0, 0, hostLoc)`, `end = start + duration`
- (elapsed). **Kept only if all four hold** (B17): `start`'s wall
- minute is `m`; `end`'s wall minute is `m + duration_min` on the same
- local date (or `1440` = 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
+ (elapsed). Kept only if all four hold (B17): `start`'s wall minute
+ is `m`; `end`'s wall minute is `m + duration_min` on the same local
+ date (or `1440`); `ToUTC(FormatLocal(start)) == start.Unix()`;
+ `ToUTC(FormatLocal(end)) == end.Unix()`.
+4. **Busy.** Fetched once over `[from - 1 day, to + 1 day)` from
+ `EventsInRange(host)` — **after the two fixes in §4.1** — using
`OccStartUTC`/`OccEndUTC`: keep `Busy(kind) && status != cancelled`;
tentative is busy; the event being rescheduled is excluded by id; a
- tentative event whose booking is `held` with `hold_expires_at <= now`
- is NOT busy (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.
+ tentative event whose booking is `held` with `hold_expires_at <=
+ now` is NOT busy. **All-day**: the occurrence's dates are recovered
+ as `time.Unix(OccStartUTC).In(eventZoneOrUTC)` through
+ `time.Unix(OccEndUTC-1).In(…)`, then each date's `DayBounds` in the
+ HOST's zone. All-day notes are not busy.
5. **Buffer.** Drop if `[start - buffer, end + buffer)` overlaps busy.
-6. Return ascending `Slot{StartUTC, EndUTC, Local}`; the page groups by
- the booker-zone date of START.
+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)
+### 4.1 Two fixes to the range query it depends on
-`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.
+Both are calendar bugs, visible in every view, owned here because busy
+time must be what the calendar shows.
-### 4.2 Cost (r2 B7)
+- **Moved override of a finished series (r2 B3).** `EventsInRange`
+ drops an override whose master the query did not select. Fix:
+ override rows returned by `rangeRows` contribute their `SeriesID` to
+ the set `overridesFor` loads, so pass 2 places them.
+- **Long final occurrence (r3 f7).** `rangeRows` excludes a master
+ when `until_utc < from`, but `UNTIL` bounds the last occurrence's
+ START, not its end. Fix: the exclusion becomes `until_utc +
+ (end_utc - start_utc) < from`, using the master's own duration.
+
+Regression tests for both in `occurrence_test.go`: a daily series
+ending in three days with its last occurrence moved ten days out shows
+on that day; a series whose final occurrence spans four days shows on
+its third.
+
+### 4.2 Cost
`?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.
+404. The day list is ONE `EventsInRange` over the horizon; a day page
+ONE over that day ±1. Every public route passes the gate in §7.2. A
+process-wide semaphore of **4** concurrent public slot computations
+guards `FreeSlots` from public handlers; when it is full the response
+is **503** with `Retry-After: 5`, and never a page computed from
+truncated busy data (r3 f8).
---
## 5. The public flow
Public routes sit beside `/feed/{token}.ics`: inside `csrf.Protect`,
-outside the session group. 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.
+outside the session group, each wrapped by the gate (§7.2), which also
+sets `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. The
+token is in the path and WILL be in the access log — the feed's own
+residual, stated.
### 5.1 `GET /book/{handle}/{code}`
`PublicBookingPage` → 404 byte-identical to the router's own. The page:
title, description, duration, host's first name (opened `NameSealed`)
-or handle, a zone `<select>` of `CommonZones` **plus the host's zone if
-it is not among them**; `?zone=` outside that set → the host's zone.
+or handle, a zone `<select>` of `CommonZones` plus the host's zone;
+`?zone=` outside that set → the host's zone.
No `?day=`: the next days with any free slot, as links carrying `day`
(booker-zone date) and `zone`. With a valid `day`: that day's slots as
`<form method=post>`s carrying hidden `start` (UTC unix) and `zone`.
-Pressing one posts WITHOUT `name`; the handler 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.
+Pressing one posts WITHOUT `name`; the handler re-verifies the slot and
+renders the page with it fixed and the details form (`name`, `address`,
+`note`) beneath, `start`/`zone` hidden. Two POSTs, no script.
+`booking.js` may only preselect the zone.
### 5.2 `POST /book/{handle}/{code}` with `name` — hold
-Body ≤ 16 KiB (`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.
+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 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).
+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}"`,
+notes = the note, kind meeting, `tz` = host zone, local strings from
+the instant), the booking (`held`, `hold_expires_at = min(now + 30m,
+start)`, token hashed and sealed, title and description snapshotted,
+`ip_fp`, `ip_forget_at`), and `booking_mails(hold, booker, 0)`.
-Outside the mutex: try 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."
+Outside the mutex: try the row once (§6). **Whatever happens, the hold
+stands** (B16). Response 200 "Check your mail" with the address and
+the expiry in the booker's zone; if the try errored, plus "If nothing
+arrives in a few minutes, the hold lapses at {expiry} and you can try
+again."
### 5.3 `GET /book/m/{token}`
-`BookingByToken` → 404. Reads the EVENT for status and deletion. Then:
+`BookingByToken` → 404. Reads the event ROW for `status`, `deleted_at`,
+times, `sequence`. Then:
-- `held` and `now < hold_expires_at`: what and when; ONE form → `POST
- …/confirm`, button "Confirm this booking"; "Didn't ask for this? Do
+- `held`, `now < hold_expires_at`: what and when; ONE form → `POST
+ …/confirm`, "Confirm this booking"; "Didn't ask for this? Do
nothing; it expires at …". Nothing changes on GET.
-- `held` past expiry, or `expired`: "This hold expired at …", 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.
+- `held` past expiry, or `expired`: "This hold expired at …".
+- `confirmed`, event not cancelled and not deleted: manage (§5.5).
+- otherwise: "cancelled", by whom (`cancelled_by`, or "the host" when
+ the event is cancelled/deleted without a booking transition having
+ recorded it — which §5.5 makes impossible, so this branch is
+ defensive).
### 5.4 `POST /book/m/{token}/confirm`
-One transaction: `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
+One transaction: read `events.start_utc`; `UPDATE bookings SET
+state='confirmed', confirmed_at WHERE id=? AND state='held' AND
+hold_expires_at > ? AND ? < start_utc`; `RowsAffected == 1` or stop and
+re-render §5.3. Then `UPDATE events SET status='confirmed', sequence =
+sequence + 1`; attendee row; queue `(confirmed, host, seq)`. After
+commit: the host's default reminder (its own function; the booking is
+correct without it). Then try the row once. A second press affects
+zero rows and does nothing.
+
+### 5.5 The manage page, the allowlist, and the host's authority
+
+Manage shows: the snapshotted title and description, when (booker
+zone; host zone if different), who (host name/handle and address), and
+**Add to calendar** (`…/calendar.ics`), **Reschedule**, **Cancel**.
+
+**The public allowlist.** Every public page, booker mail and the ICS
+may contain ONLY: the booking's snapshotted title and description, the
+event's `start`/`end`/`status`/`sequence`/`deleted_at`, the host's
+name/handle and address, the booker's own name and address,
+`cancelled_by`, the booking's state. **Never** the event's title,
+notes, location or attendee list. Public templates receive a
+`publicBooking` struct with exactly those fields; they never receive an
+event or a booking. A test edits notes, location and attendees as the
host after confirmation and asserts none reaches manage, ICS or mail.
-**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`).
+**The host's authority (B18).** The host edits the event through the
+ordinary form, and `UpdateEvent`/`DeleteEvent` enforce, inside their
+transactions, for any event a booking references:
+
+- Refused with a `ValidationError` naming the booking: an `RRule`
+ ("This is a booked meeting; it cannot repeat."); `all_day`; a
+ duration outside 5–480 minutes or with `end <= start`; a start/end
+ pair whose wall-clock length differs from its elapsed length in the
+ event's `tz` (the B17 check, applied to the edit); `status =
+ confirmed` when the booking is `cancelled` or `expired` ("This
+ booking was cancelled. Make a new event instead."); `status` other
+ than confirmed/cancelled; any update to a soft-deleted event.
+- `status = cancelled` on a `confirmed` booking → booking `cancelled`,
+ `cancelled_by = host`, reminder queue rows deleted, unsent mail
+ deleted, `(cancelled, booker, seq)` queued.
+- `DeleteEvent` → the same, plus `sequence = sequence + 1` on the
+ soft-deleted row; a `held` booking → `expired`.
+- `sequence` bumps are `gorm.Expr("sequence + 1")` everywhere.
+- The reminder claim path drops a queue row whose event is `cancelled`.
+
+A stale host form is covered without a revision field: the only edits
+a stale form could apply are ones the invariants above refuse or ones
+that are harmless to the booking (title, notes, location, which the
+booker never sees). Adding a revision to the general event form is
+explicitly not done here.
### 5.6 Reschedule and cancel
`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.
+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
+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. After commit: `RescheduleReminder` per reminder. 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.
+deleted; ALL unsent mail rows deleted; `(cancelled, both, seq)` queued.
+A second press affects zero rows.
### 5.7 Transitions
| From | Trigger | To | Guard (in the UPDATE) | Same transaction |
|---|---|---|---|---|
-| — | hold | `held` | caps; slot free; `holdMu` | tentative event; `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)` |
+| — | hold | `held` | caps; slot free; `holdMu` | tentative event; snapshot; `mail(hold, booker, 0)` |
+| `held` | confirm POST | `confirmed` | `hold_expires_at > now AND now < events.start_utc` | event confirmed, seq+1; attendee; `mail(confirmed, host, seq)` |
+| `held` | sweep | `expired` | `hold_expires_at <= now` | event soft-deleted; reminder queue rows deleted; unsent mail deleted |
+| `held` | host cancels/deletes event | `expired` | — | unsent mail deleted |
+| `confirmed` | reschedule | `confirmed` | slot free; `reschedules < 5`; `holdMu`; `events.sequence = rev` | five time columns + seq+1; `reschedules+1`; unsent `rescheduled` deleted; `mail(rescheduled, both, seq)` |
+| `confirmed` | booker cancels | `cancelled` | `state='confirmed'` | event cancelled, seq+1; queue rows deleted; all unsent mail deleted; `cancelled_by=booker`; `mail(cancelled, both, seq)` |
+| `confirmed` | host cancels/deletes event | `cancelled` | `state='confirmed'` | seq+1; queue rows deleted; all unsent mail deleted; `cancelled_by=host`; `mail(cancelled, booker, seq)` |
| any | sweep at `ip_forget_at` | same | `ip_fp IS NOT NULL` | `ip_fp = NULL` |
Every transition is a conditional `UPDATE` checking `RowsAffected ==
1`; zero rows → re-read and render, never retry. No mail is sent inside
-a transaction; the row is.
+a transaction; rows are.
+
+### 5.8 The ICS (`WriteBookingICS`)
+
+A dedicated writer in `booking_ics.go` taking the `publicBooking`
+struct and the event's opened UID. `WriteICS` is untouched and still
+says `METHOD:PUBLISH` for calendar exports; it is never called here.
+
+```
+BEGIN:VCALENDAR / VERSION:2.0 / PRODID / CALSCALE:GREGORIAN
+METHOD:REQUEST -- state confirmed, event not cancelled/deleted
+METHOD:CANCEL -- otherwise
+BEGIN:VEVENT
+UID:{event UID} -- stable across reschedules
+DTSTAMP:{event updated_at}
+SEQUENCE:{event sequence}
+DTSTART;TZID={event tz}:{start_local} DTEND;TZID=…:{end_local}
+SUMMARY:{snapshot title} DESCRIPTION:{snapshot description, if any}
+ORGANIZER;CN={host name or handle}:mailto:{host address}
+ATTENDEE;CN={booker name};PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:{booker}
+STATUS:CONFIRMED | CANCELLED -- effective state, not events.status alone
+END:VEVENT / END:VCALENDAR
+```
+
+Served at `GET /book/m/{token}/calendar.ics` for `confirmed` and
+`cancelled` bookings (`text/calendar; charset=utf-8`,
+`Content-Disposition: attachment; filename="booking.ics"`); `held` and
+`expired` → 404. The event row is read including soft-deleted ones, so
+a host-deleted booking still exports its CANCEL. Folding, escaping and
+date formatting reuse `ics_export.go`'s helpers. No `URL:`, no
+`LOCATION`, no other attendee.
---
## 6. Mail
-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.
+Plain text through `mail.Sender`; one configured sender; the host's
+address is in the BODY and as `ORGANIZER`. Bodies are `text/template`
+files in `templates/mail/`, rendered at send time from allowlisted
+fields and the CURRENT state (B19). No booker content in any log line.
| kind | to | Says |
|---|---|---|
-| `hold` | booker | "Somebody — we hope you — asked to book {title} with {host} on {when, 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, 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).
+| `hold` | booker | "Somebody — we hope you — asked to book {title} with {host} on {when}. If that was you, confirm it here: {link}. If not, do nothing; it expires at {expiry}." |
+| `confirmed` | host | "{name} ({address}) booked {title}, {when}. Note: {note}. It is on your calendar: {event link}." If the booking is no longer confirmed when this renders, the body says so in its first line. |
+| `rescheduled` | booker, host | The current time in the recipient's zone; the manage link (booker) or event link (host). "If you added this to a calendar, download it again — the file does not update itself." |
+| `cancelled` | booker, host | What, when it had been, `cancelled_by` in words. |
+
+**Sending.** All mail — reminders included — goes through ONE wrapper
+around the configured sender, `boundedSender`:
+
+- At most **4** sends in flight process-wide (a semaphore). A fifth
+ caller gets `ErrMailBusy` immediately and its row is retried later.
+- Each attempt runs `Send` in a goroutine and waits at most
+ `min(20s, remaining budget)`, where the budget is the caller's
+ context deadline. Past that it returns `ErrMailTimeout`; the
+ goroutine is abandoned and releases its semaphore slot when the
+ underlying send eventually returns or the OS closes the connection.
+- **Honest limit (r3 f3):** rastrillo's `mail.SMTP` ignores its
+ context and sets no dial or protocol deadline, so an abandoned send
+ can hold a goroutine and a socket until the kernel gives up. The
+ semaphore bounds how many. The fix belongs upstream — a `mail.SMTP`
+ that honours `ctx` — and is recorded in the calendar spec's debt list
+ in the same commit. This spec does not claim to bound what it cannot.
+
+Success → acknowledge with the claim id. Error → fail with the claim
+id (`due_at = now + claimLease`). Nothing is concluded from an error
+except "try again later" (B16).
---
## 7. Abuse
-### 7.1 Durable caps, from `bookings` rows, checked inside `holdMu` before slot arithmetic
+### 7.1 Durable caps, from `bookings` rows, inside `holdMu`, before slot arithmetic
| Cap | Key | Window | Limit |
|---|---|---|---|
@@ -466,104 +538,118 @@ and the drain retries. Nothing is ever concluded from an error except
Over → 429 "Too many bookings in progress. Try again later."
-### 7.2 The cheap gate (r2 B7)
+### 7.2 The gate (r2 B7, r3 f8)
-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.
+An in-process token bucket per `ip_fp` on every public route: 60
+requests/minute, 429 beyond. **Bounded:** at most 10,000 keys; a key
+idle for five minutes is evicted on the next insert; when full, the
+oldest idle key goes. Memory only, resets on restart. The gate also
+sets the two public headers. It is the cheap wall in front of the
+semaphore (§4.2) and the durable caps (§7.1).
### 7.3 What this does not stop
A patient adversary with many mailboxes and addresses books real slots
-one at a time. So does a patient human. The host cancels them and, if
-it continues, unpublishes. Nothing here stops a determined person; it
-stops a script.
+one at a time. The host cancels them and, if it continues, unpublishes.
-### 7.4 The oracle, stated as new (r1 f8)
+### 7.4 The oracle, stated as new
-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.
+A published page lets anyone ask when this host is free, and every
+granted calendar shapes the answer. Publishing is deliberate: the
+button reads **Publish** and the sentence above it reads "Anyone with
+this link can see when you are free — not what you are doing, but when
+— including time blocked by team calendars you can see." AGENTS.md's
+leak list gains this as item 5.
---
-## 8. The sweep (r2 A4, A19, B4)
+## 8. The sweep
-`drain` (the tick) runs, in order, inside the existing 45-second
-`drainDeadline` shared by everything:
+`drain` (the tick) runs, in order, inside the 45-second `drainDeadline`
+shared by everything, **cheap work first, delivery last** (r3 f3):
-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.
+1. **Expire holds**: `held AND hold_expires_at <= now` → `expired`,
+ event soft-deleted, queue rows deleted, unsent mail deleted. ≤ 200.
+2. **Forget IPs**: `ip_fp IS NOT NULL AND ip_forget_at <= now` → NULL.
≤ 500.
+3. **Reminders**, as today but through `boundedSender`, until
+ `deadline/2` or the queue is empty.
+4. **Booking mail**: claim rows `sent_at IS NULL AND due_at <= now`
+ (lease-aware), oldest first, until the deadline, each attempt
+ bounded by the remaining budget.
-`DrainOnRequest` (a signed-in page view) does ONE thing: a due
-reminder if any, else one booking mail, else one expiry. Never more.
+`DrainOnRequest` (a signed-in page view) does ONE thing: an expiry if
+any is due, else a due reminder, else one booking mail. Never more,
+and never with more than a 5-second budget.
`NextDueAt` = the earliest 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`.
+`hold_expires_at`; next `booking_mails.due_at` with `sent_at IS NULL`
+(retry times are in `due_at`, so this is claimable work); next
+`ip_forget_at` with `ip_fp IS NOT NULL`.
---
## 9. Testing
- **Parser**: every §3.1 example; inheritance; first clause without a
- day; midnight; `25-26`, `9:60`, `13pm`, `24:00` at start vs end,
+ day; midnight; `25-26`, `9:60`, `13pm`, `24:00` start vs end,
`12:30-2:30`; each separator; the en dash; three ranges; two problems
both reported; merged and touching windows.
-- **Slots**: 12–3 / 60 with and without buffer; 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`).
+- **Slots**: 12–3 / 60 with and without buffer; busy ending 5 min before
+ `from` still removes the first slot when buffered; tentative blocks;
+ lapsed unswept hold does not; cancelled does not; a note does not; a
+ recurring all-day commitment blocks the OCCURRENCE date; an empty-zone
+ all-day event blocks the host's whole day in `Asia/Tokyo`; notice
+ floor 10 min; horizon bounds `end`; `to` bounds start; spring-forward
+ and fold Sundays with 00:00–04:00: no offered slot straddles the
+ transition, both ends round-trip, every slot is exactly its duration
+ in elapsed seconds, pinned unix numbers; `DurationMin: 0` and an
+ inverted window return errors, not lists.
+- **Range query**: the moved-override-of-finished-series case and the
+ long-final-occurrence case both show.
- **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.
+ event's, no `URL:`, parses with `ParseICS`; ICS for `held` → 404;
+ reschedule with the right `rev` bumps sequence, writes `tz`, mails
+ both; the SAME form resubmitted affects nothing; cancel →
+ `METHOD:CANCEL`, unsent rows gone; host adds note/location/attendee →
+ none reaches manage, ICS or mail; host cancels via edit → booker
+ mailed, page says cancelled by host; host deletes → same, CANCEL ICS
+ with higher sequence; host edits refused: RRule, all-day, zero
+ length, 481 minutes, a DST-straddling pair, reviving a cancelled
+ booking, editing a deleted event; page title changed after hold →
+ manage and ICS still show the snapshot.
+- **Zone change**: host moves Dublin → Tokyo; reschedule writes
+ `tz=Asia/Tokyo` and all four time columns agree (`ToUTC(start_local,
+ tz) == start_utc`).
+- **Mail queue**: token recoverable after a simulated restart (insert
+ row, `tick`, link works); a refused host mail is retried at `due_at`
+ and gives up at `MaxReminderAttempts`; a stale `claim_id` cannot
+ acknowledge or fail a row another claim holds; two drains do not
+ send one row twice; cancel deletes every unsent row; `NextDueAt`
+ reflects `due_at` not the lease; `boundedSender` returns
+ `ErrMailTimeout` within budget against a stalling sender and
+ `ErrMailBusy` when four are in flight.
+- **Caps, gate, semaphore**: sixth open hold per IP → 429; 21st attempt
+ in 24h → 429 with none open; `MaxReschedules`; 61st public GET in a
+ minute → 429; the gate holds ≤ 10,000 keys after 20,000 distinct IPs;
+ `?day=` past the horizon → 404; five concurrent public slot
+ computations → one 503 with `Retry-After`.
- **Sweep**: `tick` expires, soft-deletes, frees; retries owed mail;
- nulls `ip_fp` at `ip_forget_at`; `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.
+ nulls `ip_fp` at `ip_forget_at`; reminders still progress with 30
+ mail rows queued behind a slow sender; `DrainOnRequest` does one
+ thing; `NextDueAt` returns each source when earliest.
+- **`Zone()`**: 100 goroutines × garbage under `-race`; no failures
+ cached.
- **Reminders**: a cancelled event's queue row is dropped by the claim
path.
-- **The one rule**: booker name, address, note, page title, description,
- availability, and the token — none in the raw bytes.
+- **The one rule**: booker name, address, note, the token, the
+ snapshotted title and description, the page's availability — none in
+ the raw bytes.
- **Logs**: no address, no token, after a failed send.
- **Browser**: JS off — book, "check your mail", open the link, press
Confirm, see Reschedule. JS on — the settings pane fills in.
@@ -574,19 +660,21 @@ next `ip_forget_at` with `ip_fp IS NOT NULL`.
- 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; the change mail says to download it again.
+- **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.
+- **Automatic calendar sync.** The ICS is a download; the change mail
+ says to download it again.
+- **A revision field on the general event form.** Booked events are
+ protected by invariants instead (§5.5).
+- **A context-honouring SMTP sender.** Upstream (§6).
- Locale; retention (rows stay, sealed, minus `ip_fp`).
---
-## 11. Changes to AGENTS.md in the same commit
+## 11. Changes to AGENTS.md and the calendar spec in the same commit
-- Settled decisions: rows B1, B2, B4, B5, B8, B11, B12, B13, B14, B15,
- B16, B17.
+- Settled decisions: rows B1, B2, B4, B5, B8, B11–B21.
- Frontend: **"Except a BOOKER, who typed their address to be mailed.
Booking mail is the one outbound to a non-team address."**
- Data: **"Booker name, address, note and TOKEN are sealed; the address
@@ -595,54 +683,57 @@ next `ip_forget_at` with `ip_fp IS NOT NULL`.
- 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."**
+- Data: **"A booked event keeps its invariants under every host edit:
+ timed, 5–480 minutes, wall = elapsed, confirmed or cancelled, no
+ rule, never revived."**
+- Reminders → **Ticks**: **"`NextDue` is the earliest CLAIMABLE
+ instant across reminders, hold expiries, owed booking mail and
+ IP-forgets. Retry times live in `due_at`. New scheduled work goes
+ here, never in a goroutine. Cheap work runs before delivery;
+ reminders get the first half of the delivery budget."**
- Reminders: **"No sender error is definite. Never free a resource
- because a send 'failed'."**
+ because a send 'failed'. Every send goes through `boundedSender`."**
- Time: **"`Zone()` takes bounded input on public routes: `CommonZones`
plus the host's own zone."**
- Time: **"A booking slot is the same length on the wall clock and the
elapsed clock, or it is not offered."**
- The one rule's exclusions, item 5: the published-page oracle.
+- `2026-09-02-calendar-design.md` §7 debt list: **"rastrillo `mail.SMTP`
+ ignores its context; an abandoned send holds a goroutine and a
+ socket until the kernel closes it. Bounded to four in flight here;
+ fixed properly upstream."**
---
## 12. Review
-Reviewer both rounds: **`gpt-6-astra`** via `codex-cli 0.153.0`,
-2026-09-07.
+Reviewer: **GPT-6** via `codex-cli 0.153.0` (identified itself as
+`gpt-6-astra` in round 2; rounds 1 and 3 could not expose the runtime
+identifier). 2026-09-07.
-**Round 1: REWRITE, 21 findings, all accepted** (dispositions were in
-revision 2 and are folded into the text above; the round-2 table below
-records what still needed work).
+**Round 1: REWRITE, 21 findings, all accepted.** **Round 2: REWRITE,
+7 new + 11 reopened, all accepted.** Both tables are in the git history
+of this file (`9a28d7f`, `0999c0d`, `13b43a4`).
-**Round 2: REWRITE.** Of the 21: 12 HOLD; 7 PARTIAL; 4 DO NOT HOLD.
-Plus 7 new. Dispositions:
+**Round 3: REWRITE.** 11 new findings; of round 2, A11, A21, B2, B7
+PARTIAL and B4 DOES NOT HOLD. Dispositions:
| Ref | Finding | Disposition |
|---|---|---|
-| 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.
+| 1 | Zero-length host edit loops slot generation inside the mutex | **Accepted — B18.** Invariants on booked events; `ComputeSlots` refuses bad input (§4.0, §5.5). |
+| 2 | Reschedule after a host zone change writes a contradictory record | **Accepted — B21.** `tz` written with the four time columns (§5.6). |
+| 3 | The timed sender bounds waiting, not sending; reminders unbounded; 20 s ignores the shared deadline | **Accepted as far as this repo can, scoped where it cannot.** `boundedSender` wraps ALL mail, caps in-flight at 4, waits `min(20s, remaining budget)`; cheap sweep work runs before delivery. The socket-level bound needs rastrillo's `mail.SMTP` to honour its context; recorded as upstream debt (§6, §11). |
+| 4 | Queue revisions do not identify content; a late "confirmed" after a cancel | **Accepted — B19.** Mail says the current state; cancel/expire delete every unsent row; a `confirmed` body opens by saying the booking is no longer confirmed if so; already-sent rows cannot be recalled and the spec says it. |
+| 5 | Ordinary host edits contradict terminal booking states | **Accepted — B18.** Reviving refused; deleted events refused; no general-form revision, stated in §10. |
+| 6 | `NextDueAt` advertises leased work; claims have no identity | **Accepted — B13.** `due_at` carries the retry time; `claim_id` on claim, ack and fail (§2.5, §8). |
+| 7 | `UNTIL` exclusion misses a long final occurrence | **Accepted, a second calendar bug.** `until_utc + duration < from` (§4.1). |
+| 8 | Unbounded bucket; unbounded work per request | **Accepted.** Bucket capped at 10,000 with idle eviction; a 4-wide semaphore on public slot computation, 503 when full (§4.2, §7.2). Expansion cost per request is the calendar's own and is not re-bounded here. |
+| 9 | ICS contract missing; held/expired undefined; effective status | **Accepted.** §5.8. |
+| 10 | Live page text changes the ICS without a new `SEQUENCE` | **Accepted — B20.** Title and description snapshotted onto the booking. |
+| 11 | Token generation unspecified | **Accepted — B14.** 32 bytes `crypto/rand`, hex, collision handling. |
+
+Rejected: none. Scoped rather than solved: the socket-level send
+bound (finding 3, upstream) and the per-request cost of recurrence
+expansion (finding 8, the calendar's own).
+
+**Round 4** — to be recorded below.