oficina / calendar Public

booking: spec revision 2 after review — 21 findings, 21 accepted

GPT-6 via codex-cli 0.153.0 returned REWRITE. The worst was a
confirmation that fired on GET: every link-scanning mail gateway would
have confirmed bookings nobody asked for. Now the link opens a page
with a button, and the button is a POST. Slugs become generated codes
(a slug is prose joined to events); the booking sequence goes (one
counter, the event's); the ICS loses its URL: line (a management
capability exported to wherever a calendar syncs); holds serialise on a
process mutex (SQLite begins deferred, so two transactions both read
free); mail becomes an outbox column the drain retries; and Zone() gets
bounded public input because its cache is an unsynchronised map that
caches failures. §12 records every disposition.

The first draft of the implementation plan is committed alongside and
is now out of date against this revision; it is rewritten next.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 0999c0d7ea602e4b6dca7f24445e067d6fdefb6d parent 9a28d7f
2 files changed, +1697 −300
  • docs/superpowers/plans/2026-09-07-booking-plan.md +1218 −0
  • docs/superpowers/specs/2026-09-07-booking-design.md +479 −300
diff --git a/docs/superpowers/plans/2026-09-07-booking-plan.md b/docs/superpowers/plans/2026-09-07-booking-plan.md
new file mode 100644
index 0000000..d98b108
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-07-booking-plan.md
@@ -0,0 +1,1218 @@
+# Booking Pages Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A public "book time with me" page per host, with plain-text availability, email-link confirmation, a tokenised ICS, 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. Everything public lives beside `/feed/{token}.ics` in the chi router — inside CSRF, outside the session group. Holds expire on the existing tick.
+
+**Tech Stack:** Go 1.2x, rastrillo v0.23.0 (chi router, GORM + modernc SQLite, `mail.Sender`, `csrf.Protect`, `carlos.Tick`), html/template, Playwright for the browser gate.
+
+**Spec:** `docs/superpowers/specs/2026-09-07-booking-design.md` — read it first, in full. Every task below cites the section it implements. Where this plan and the spec disagree, the spec wins and this plan is wrong.
+
+## 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 (weekday, start_min, end_min) and `booker_zone` are plaintext. Tokens are stored as `HashFeedToken(token)` only.
+- **No title, place, note or address in any log line, error string or metric label.** Use `scrubErr(err)` on every logged error and a `"stage"` key.
+- **A row that isn't yours 404s, never 403s.** Unknown handle, slug or token → `http.NotFound`.
+- **Never bind a request body onto a model.** Explicit `Input` structs and explicit column allowlists in every `Updates`.
+- **Never `sessions.UserID`.** Use `CurrentPerson(r)`.
+- **Migrations are generated**, never hand-written: delete `internal/calendar/migrations/schema.sql`, add the model to `Models`, run `go run github.com/carlosframework/rastrillo/cmd/rastrillo migration generate`, which writes the numbered file AND refreshes `schema.sql`. Never edit a shipped migration.
+- **300-line cap on every `.go` and `.js` file** (`internal/guardstest`). Split files by responsibility before they get there.
+- **`rst-*` attribute grammar**, never class spellings, except the seven class-only utilities (`rst-sr-only`, `rst-mono`, `rst-grow`, `rst-m-hide`, `rst-danger`, `rst-nm`, `rst-cell-mut`). This app's components take `cal-`.
+- **No affordance may require script.** Every page below works with JavaScript off; `booking.js` is a debounced fetch and nothing else.
+- **Every range is half-open.** `[start, end)`. Use `DayBounds`, never `Add(24h)`.
+- **Time is `start_local` + `tz`; UTC is derived.** Slots are computed in the host's zone.
+- **Work inside the request.** No goroutines for mail or sweeps.
+- **`make ci` before every commit you push**: `go vet`, `gofmt`, `go test ./...`, `migration check`. Run go commands with the sandbox off if the build cache is read-only.
+- **Commit trailer:** every commit ends with `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`.
+- **Test package:** tests live in `internal/calendartest` (package `calendartest`, imports `calendar "amadan.net/oficina/calendar/internal/calendar"`) unless they need unexported names, in which case they are `_test.go` files in `internal/calendar`. Helpers already there: `signedIn(t, addr)`, `getAs`, `postAs`, `follow`, `newAppWithMailer(t, *pickyMailer)`, `tick(t, h)`, `app.LastMailForTest()`, `app.GrepDatabaseForTest(t, needle)`, `app.DBForTest()`, `calendar.RequestForTest(session, method, target, form)`.
+
+---
+
+## File map
+
+| File | Responsibility | Task |
+|---|---|---|
+| `internal/calendar/models_booking.go` | `BookingPage`, `BookingWindow`, `Booking` structs; state constants | 1 |
+| `internal/calendar/models.go` | `Person.Handle`; register three models | 1 |
+| `internal/calendar/seal/seal.go` | new purposes | 1 |
+| `internal/calendar/migrations/000N_*.sql`, `schema.sql` | generated | 1 |
+| `internal/calendar/store_booking_page.go` | handle, page CRUD, public lookup | 1 |
+| `internal/calendar/avail.go` | `ParseAvailability`, `DescribeWindows` | 2 |
+| `internal/calendar/quickadd_when.go` | export nothing; `reRange`, `applyMeridiem`, `clock`, `weekdayAlt`, `weekdays` are already package-level and reusable | 2 |
+| `internal/calendar/slots.go` | `Window`, `Slot`, `Busy`, `SlotRule`, `ComputeSlots` (pure) | 3 |
+| `internal/calendar/store_slots.go` | `BusyFor`, `FreeSlots` | 3 |
+| `internal/calendar/store_booking_flow.go` | hold / by-token / confirm / reschedule / cancel / expire / caps | 4 |
+| `internal/calendar/store_reminder_next.go` | `NextDueAt` widened | 4 |
+| `internal/calendar/handlers_booking_settings.go` | host settings handlers | 5 |
+| `internal/calendar/templates/booking_pages.html`, `booking_page_form.html`, `_bookingpane.html` | host UI | 5 |
+| `internal/calendar/templates/settings.html` | handle field + link | 5 |
+| `internal/calendar/static/booking.js` | pane fetch; zone preselect | 5, 6 |
+| `internal/calendar/handlers_booking_public.go` | page GET/POST (hold) | 6 |
+| `internal/calendar/handlers_booking_manage.go` | confirm/manage/reschedule/cancel | 6 |
+| `internal/calendar/booking_ics.go` | one-VEVENT ICS with METHOD | 6 |
+| `internal/calendar/booking_mail.go` + `templates/mail/*.txt` | four mails | 6 |
+| `internal/calendar/templates/book_page.html`, `book_sent.html`, `book_manage.html`, `book_gone.html` | public UI | 6 |
+| `internal/calendar/handlers_jobs.go`, `store_drain.go` | sweep step | 7 |
+| `internal/calendar/routes.go` | all routes | 5, 6, 7 |
+| `internal/calendar/render.go` | parse new templates; `_bookingpane.html` into `fragments` | 5, 6 |
+| `internal/calendar/static/calendar.css` | `cal-book*` block | 5, 6 |
+| `internal/calendartest/avail_test.go`, `slots_test.go`, `booking_http_test.go`, `booking_sweep_test.go` | tests | 2–7 |
+| `internal/calendartest/events_test.go` | leak test fixture | 7 |
+| `test/browser/quickadd.mjs` → add `booking.mjs` and a Go driver | browser passes | 7 |
+| `AGENTS.md` | §11 of the spec | 8 |
+
+---
+
+### Task 1: Models, migrations, seal purposes, page store
+
+Spec §2. Everything later depends on these names.
+
+**Files:**
+- Create: `internal/calendar/models_booking.go`
+- Modify: `internal/calendar/models.go` (Person struct ~line 37; `Models` slice line 10)
+- Modify: `internal/calendar/seal/seal.go` (purpose constants ~line 41)
+- Create: `internal/calendar/store_booking_page.go`
+- Generate: `internal/calendar/migrations/0005_*.sql`, `schema.sql`
+- Test: `internal/calendartest/booking_store_test.go`
+
+**Interfaces — Produces:**
+
+```go
+// models_booking.go
+const (
+ BookingHeld = "held"
+ BookingConfirmed = "confirmed"
+ BookingCancelled = "cancelled"
+ BookingExpired = "expired"
+)
+
+type BookingPage struct {
+ ID int64 `gorm:"primaryKey"`
+ PersonID int64 `gorm:"column:person_id;not null;uniqueIndex:idx_booking_page_slug"`
+ Slug string `gorm:"column:slug;not null;uniqueIndex:idx_booking_page_slug"`
+ TitleSealed []byte `gorm:"column:title_sealed"`
+ DescriptionSealed []byte `gorm:"column:description_sealed"`
+ DurationMin int `gorm:"column:duration_min;not null"`
+ BufferMin int `gorm:"column:buffer_min;not null"`
+ NoticeMin int `gorm:"column:notice_min;not null"`
+ HorizonDays int `gorm:"column:horizon_days;not null"`
+ AvailabilitySealed []byte `gorm:"column:availability_text_sealed"`
+ Active bool `gorm:"column:active;not null"`
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+ UpdatedAt time.Time `gorm:"column:updated_at;not null"`
+}
+
+type BookingWindow struct {
+ ID int64 `gorm:"primaryKey"`
+ PageID int64 `gorm:"column:page_id;not null;index"`
+ Weekday int `gorm:"column:weekday;not null"` // 0 = Sunday, time.Weekday
+ StartMin int `gorm:"column:start_min;not null"`
+ EndMin int `gorm:"column:end_min;not null"`
+}
+
+type Booking struct {
+ ID int64 `gorm:"primaryKey"`
+ PageID int64 `gorm:"column:page_id;not null;index"`
+ EventID *int64 `gorm:"column:event_id;index"`
+ TokenHash string `gorm:"column:token_hash;not null;uniqueIndex"`
+ State string `gorm:"column:state;not null;index"`
+ BookerNameSealed []byte `gorm:"column:booker_name_sealed"`
+ BookerAddressSealed []byte `gorm:"column:booker_address_sealed"`
+ BookerAddressFP string `gorm:"column:booker_address_fp;not null;index"`
+ NoteSealed []byte `gorm:"column:note_sealed"`
+ BookerZone string `gorm:"column:booker_zone;not null"`
+ HoldExpiresAt int64 `gorm:"column:hold_expires_at;not null;index"`
+ ConfirmedAt *int64 `gorm:"column:confirmed_at"`
+ CancelledAt *int64 `gorm:"column:cancelled_at"`
+ Sequence int `gorm:"column:sequence;not null"`
+ CreatedIPHash string `gorm:"column:created_ip_hash;not null;index"`
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+ UpdatedAt time.Time `gorm:"column:updated_at;not null"`
+}
+
+// models.go — on Person, after Zone:
+Handle *string `gorm:"column:handle;uniqueIndex"` // nil = no public pages. SQLite's UNIQUE admits many NULLs.
+
+// seal/seal.go
+PurposeBookingTitle = "booking.title"
+PurposeBookingDescription = "booking.description"
+PurposeBookingAvailability = "booking.availability"
+PurposeBookerName = "booking.booker.name"
+PurposeBookerAddress = "booking.booker.address"
+PurposeBookerNote = "booking.booker.note"
+PurposeBookingIP = "booking.ip" // Fingerprint only, never Seal
+
+// store_booking_page.go
+type BookingPageInput struct {
+ Slug, Title, Description, Availability string
+ DurationMin, BufferMin, NoticeMin, HorizonDays int
+ Active bool
+}
+type BookingPageView struct {
+ BookingPage
+ Title, Description, Availability string
+ Windows []Window // from slots.go, Task 3 — declare Window in models_booking.go so Task 1 compiles alone
+}
+var ErrHandleTaken = errors.New("calendar: handle taken")
+var reservedHandles = map[string]bool{"m": true, "new": true, "static": true}
+
+func ValidateHandle(h string) error // [a-z0-9-]{2,32}, not reserved → ValidationError{"handle", ...}
+func ValidateBookingPageInput(in *BookingPageInput) error // trims; slug [a-z0-9-]{1,40}; ranges per spec §2.2
+func (s *Store) SaveHandle(ctx, personID int64, handle string) error // "" clears; unique violation → ErrHandleTaken
+func (s *Store) PersonByHandle(ctx, handle string) (Person, error) // ErrNotFound
+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 // 404 if not owner; rewrites windows in the same transaction
+func (s *Store) SetBookingPageActive(ctx, personID, pageID int64, active bool) error
+func (s *Store) BookingPages(ctx, personID int64) ([]BookingPageView, error)
+func (s *Store) BookingPage(ctx, personID, pageID int64) (BookingPageView, error) // ErrNotFound
+func (s *Store) PublicBookingPage(ctx, handle, slug string) (BookingPageView, Person, error) // active only; ErrNotFound for every miss
+```
+
+Put `type Window struct { Weekday time.Weekday; StartMin, EndMin int }` in `models_booking.go` (not a GORM model — it is the plain value `BookingWindow` rows are read into).
+
+- [ ] **Step 1: Write the failing store test**
+
+```go
+// internal/calendartest/booking_store_test.go
+package calendartest
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+func TestABookingPageRoundTripsWithItsWindows(t *testing.T) {
+ app, _ := signedIn(t, "host@example.com")
+ ctx := context.Background()
+ host, err := app.StoreForTest().PersonByAddress(ctx, "host@example.com")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := app.StoreForTest().SaveHandle(ctx, host.ID, "paul"); err != nil {
+ t.Fatalf("SaveHandle: %v", err)
+ }
+ in := calendar.BookingPageInput{
+ Slug: "chat", Title: "Quick chat", Description: "Fifteen minutes.",
+ Availability: "mondays 12-3", DurationMin: 15, BufferMin: 10, NoticeMin: 120, HorizonDays: 30, Active: true,
+ }
+ windows := []calendar.Window{{Weekday: time.Monday, StartMin: 12 * 60, EndMin: 15 * 60}}
+ page, err := app.StoreForTest().CreateBookingPage(ctx, host.ID, in, windows)
+ if err != nil {
+ t.Fatalf("CreateBookingPage: %v", err)
+ }
+ got, _, err := app.StoreForTest().PublicBookingPage(ctx, "paul", "chat")
+ if err != nil {
+ t.Fatalf("PublicBookingPage: %v", err)
+ }
+ if got.ID != page.ID || got.Title != "Quick chat" || len(got.Windows) != 1 || got.Windows[0].EndMin != 900 {
+ t.Errorf("round trip lost something: %+v", got)
+ }
+ // Sealed: the raw file never holds the title or the sentence.
+ 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 TestAnInactivePageAndAnUnknownHandleBothVanish(t *testing.T) {
+ app, _ := signedIn(t, "host@example.com")
+ ctx := context.Background()
+ st := app.StoreForTest()
+ host, _ := st.PersonByAddress(ctx, "host@example.com")
+ _ = st.SaveHandle(ctx, host.ID, "paul")
+ page, _ := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{
+ Slug: "chat", Title: "Chat", DurationMin: 30, HorizonDays: 30, Active: true}, nil)
+ if err := st.SetBookingPageActive(ctx, host.ID, page.ID, false); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := st.PublicBookingPage(ctx, "paul", "chat"); !calendar.NotFound(err) {
+ t.Errorf("paused page: err = %v, want ErrNotFound", err)
+ }
+ if _, _, err := st.PublicBookingPage(ctx, "nobody", "chat"); !calendar.NotFound(err) {
+ t.Errorf("unknown handle: err = %v, want ErrNotFound", err)
+ }
+}
+
+func TestAHandleMustBeUniqueAndNotReserved(t *testing.T) {
+ app, _ := signedIn(t, "a@example.com")
+ ctx := context.Background()
+ st := app.StoreForTest()
+ a, _ := st.PersonByAddress(ctx, "a@example.com")
+ app.SignInForTest(t, "b@example.com")
+ b, _ := st.PersonByAddress(ctx, "b@example.com")
+ if err := st.SaveHandle(ctx, a.ID, "paul"); err != nil {
+ t.Fatal(err)
+ }
+ if err := st.SaveHandle(ctx, b.ID, "paul"); err != calendar.ErrHandleTaken {
+ t.Errorf("second claim: err = %v, want ErrHandleTaken", err)
+ }
+ if err := calendar.ValidateHandle("m"); err == nil {
+ t.Error("\"m\" must be reserved: it is the /book/m/{token} prefix")
+ }
+ if err := calendar.ValidateHandle("Paul Campbell"); err == nil {
+ t.Error("spaces and capitals must be refused")
+ }
+}
+```
+
+`StoreForTest()` and `PersonByAddress` may not exist. Add to `internal/calendar/testing.go`: `func (a *App) StoreForTest() *Store { return a.store }`. Check `store.go` for an existing person-by-address reader (`EnsurePersonSetup` creates; look for a plain reader) and add `PersonByAddress(ctx, address string) (Person, error)` in `store_booking_page.go` if none exists, normalising with `normalizeAddress`.
+
+- [ ] **Step 2: Run it, confirm it fails to compile**
+
+```
+go test ./internal/calendartest/ -run 'BookingPage|Handle' 2>&1 | head
+```
+Expected: undefined `BookingPageInput`, `SaveHandle`, …
+
+- [ ] **Step 3: Write the models and purposes** exactly as in Interfaces. Add `&BookingPage{}, &BookingWindow{}, &Booking{}` to `Models`. Add `Handle *string` to `Person`.
+
+- [ ] **Step 4: Generate the migration**
+
+```
+rm internal/calendar/migrations/schema.sql
+go run github.com/carlosframework/rastrillo/cmd/rastrillo migration generate
+go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
+```
+Expected: a new `0005_*.sql` (name chosen by the tool) and a refreshed `schema.sql`; check prints "models and migrations agree". Open the SQL and confirm `handle` is nullable with a unique index and no `NOT NULL`.
+
+- [ ] **Step 5: Write `store_booking_page.go`**
+
+Shape (fill every function; this is the skeleton, not a placeholder):
+
+```go
+func (s *Store) SaveHandle(ctx context.Context, personID int64, handle string) error {
+ handle = strings.ToLower(strings.TrimSpace(handle))
+ var val any = nil
+ if handle != "" {
+ if err := ValidateHandle(handle); err != nil {
+ return err
+ }
+ val = handle
+ }
+ err := s.g.WithContext(ctx).Model(&Person{}).Where("id = ?", personID).
+ Update("handle", val).Error
+ if err != nil && strings.Contains(err.Error(), "UNIQUE") {
+ return ErrHandleTaken
+ }
+ return err
+}
+```
+
+`CreateBookingPage`: validate, seal title/description/availability, insert page, insert windows, all in `s.g.Transaction`. `UpdateBookingPage`: `First(&page, "id = ? AND person_id = ?", pageID, personID)` → `ErrNotFound` on miss; `Updates(map[string]any{...explicit columns...})`; delete + reinsert windows in the same transaction. `PublicBookingPage`: join `people` on `handle = ?` then page on `slug = ? AND active = 1`; open the sealed columns; load windows ordered by weekday, start_min.
+
+- [ ] **Step 6: Run the tests**
+
+```
+go test ./internal/calendartest/ -run 'BookingPage|Handle' -count=1
+```
+Expected: PASS.
+
+- [ ] **Step 7: `make ci`, then commit**
+
+```
+make ci
+git add -A
+git commit -m "booking: pages, windows and bookings — the tables, sealed where they are content
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>"
+```
+
+---
+
+### Task 2: The availability parser and its pane fragment
+
+Spec §3.
+
+**Files:**
+- Create: `internal/calendar/avail.go`
+- Create: `internal/calendar/templates/_bookingpane.html`
+- Modify: `internal/calendar/render.go:71-77` (add `_bookingpane.html` to `fragments`)
+- Test: `internal/calendartest/avail_test.go`
+
+**Interfaces — Consumes:** `consumed`, `newConsumed`, `reRange`, `applyMeridiem`, `clock`, `weekdayAlt`, `weekdays`, `rangeSep` (all package-level in `quickadd*.go`). **Produces:**
+
+```go
+func ParseAvailability(text string) (windows []Window, problems []string)
+func DescribeWindows(ws []Window) []string // one string per weekday that has any: "Monday 12:00–15:00, 17:00–19:00"
+type BookingPaneView struct { Lines []string; Problems []string; Empty bool }
+func bookingPaneFor(text string) BookingPaneView
+```
+
+- [ ] **Step 1: Write the failing table test**
+
+```go
+// internal/calendartest/avail_test.go
+package calendartest
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+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 TestAvailabilityReadsTheSentence(t *testing.T) {
+ cases := []struct {
+ text string
+ want []calendar.Window
+ }{
+ // The one from the request, including the clause that inherits its day.
+ {"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.Saturday, "10:30", "13:00"), w(time.Sunday, "14:00", "16:00")}},
+ {"weekends 10-12", []calendar.Window{w(time.Saturday, "10:00", "12:00"), w(time.Sunday, "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")}},
+ }
+ for _, c := range cases {
+ t.Run(c.text, func(t *testing.T) {
+ got, problems := calendar.ParseAvailability(c.text)
+ if len(problems) != 0 {
+ t.Fatalf("problems = %v, want none", problems)
+ }
+ if len(got) != len(c.want) {
+ t.Fatalf("windows = %v, want %v", got, c.want)
+ }
+ for i := range got {
+ if got[i] != c.want[i] {
+ t.Errorf("window %d = %v, want %v", i, got[i], c.want[i])
+ }
+ }
+ })
+ }
+}
+
+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 TestAvailabilityRefusesWhatItCannotRead(t *testing.T) {
+ cases := []struct{ text, quoted string }{
+ {"mondays 10pm-2", "10pm-2"}, // would cross midnight
+ {"mondays 12-3, whenever", "whenever"}, // no time range
+ {"12-3", "12-3"}, // no day, and nothing to inherit
+ {"mondays 25-26", "25-26"}, // not a clock
+ }
+ for _, c := range cases {
+ t.Run(c.text, func(t *testing.T) {
+ ws, problems := calendar.ParseAvailability(c.text)
+ if len(problems) == 0 {
+ t.Fatalf("no problem reported; windows = %v", ws)
+ }
+ if !strings.Contains(strings.Join(problems, "\n"), c.quoted) {
+ t.Errorf("problems %v do not quote %q", problems, c.quoted)
+ }
+ })
+ }
+ // Both bad clauses in one sentence are both reported.
+ _, 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")
+ lines := calendar.DescribeWindows(ws)
+ want := []string{"Monday 09:00–10:00", "Tuesday 12:00–14:00, 17:00–19:00"}
+ if strings.Join(lines, "|") != strings.Join(want, "|") {
+ t.Errorf("lines = %v, want %v", lines, want)
+ }
+}
+```
+
+- [ ] **Step 2: Run, confirm compile failure.** `go test ./internal/calendartest/ -run Availability -count=1`
+
+- [ ] **Step 3: Implement `avail.go`**
+
+Algorithm (write it; the regexes below are the spec):
+
+```go
+package calendar
+
+// ParseAvailability reads "mondays 12–3, tuesdays 12–2, 5-7" into
+// weekly windows. ONE parser, in Go, shared by the live pane and the
+// save — the same rule quick add lives by.
+//
+// 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, which is the shape of the request itself: "tuesdays 12–2, 5-7"
+// is two windows on Tuesday.
+//
+// Anything not read is returned in problems with the clause quoted, and
+// the caller refuses to save while problems is non-empty. There is no
+// title here to leave a stray word in, so refusal is the safe failure.
+
+var (
+ reAvailDayRange = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?` + rangeSep + `(` + weekdayAlt + `)s?\b`)
+ reAvailDay = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?\b`)
+ reAvailGroup = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(weekdays|weekends|every\s+day|daily|all\s+days)\b`)
+ reAvailFiller = regexp.MustCompile(`(?i)\b(between|from|and|on|at)\b`)
+ reAvailSplit = regexp.MustCompile(`\s*[,;]\s*`)
+)
+
+func ParseAvailability(text string) ([]Window, []string) {
+ var out []Window
+ var problems []string
+ var days []time.Weekday // inherited across clauses
+ for _, clause := range reAvailSplit.Split(strings.TrimSpace(text), -1) {
+ if strings.TrimSpace(clause) == "" {
+ continue
+ }
+ c := newConsumed(clause)
+ if d, ok := takeAvailDays(c); ok {
+ days = d
+ }
+ if len(days) == 0 {
+ problems = append(problems, fmt.Sprintf("%q names no day", clause))
+ continue
+ }
+ ranges, bad := takeAvailRanges(c) // loop c.take(reRange) until nil; each → clock/applyMeridiem; end<=start → bad
+ rest := c.rest()
+ rest = reAvailFiller.ReplaceAllString(rest, "")
+ if len(ranges) == 0 || strings.TrimSpace(rest) != "" || bad != "" {
+ what := bad
+ if what == "" { what = clause }
+ problems = append(problems, fmt.Sprintf("Could not read %q", what))
+ continue
+ }
+ for _, d := range days {
+ for _, r := range ranges {
+ out = append(out, Window{Weekday: d, StartMin: r.start, EndMin: r.end})
+ }
+ }
+ }
+ sort.SliceStable(out, ...) // by weekday, then StartMin
+ return out, problems
+}
+```
+
+Notes for the implementer: `reRange` matches `(\d{1,2})(?::(\d{2}))?\s*(am|pm)?` … twice; use `clock(m[1], m[2])` and `clock(m[4], m[5])`, apply the "a meridiem on either end applies to both" rule exactly as `takeTimes` does (copy those six lines, do not call `takeTimes` — it returns a `clockTime` pair but also blanks only one range). Hours > 23 or minutes > 59 → bad. `weekdays` = Mon–Fri, `weekends` = Sat, Sun, `every day`/`daily`/`all days` = all seven. A day range walks forward from the first day modulo 7 to the second (`sun-tue` = Sun, Mon, Tue). `weekdays` map from `quickadd_when.go` resolves names.
+
+`DescribeWindows`: group by weekday in Sunday..Saturday order, join ranges with ", ", format `%02d:%02d–%02d:%02d` with an en dash.
+
+- [ ] **Step 4: Run the tests; make them pass.** `go test ./internal/calendartest/ -run 'Availability|DescribeWindows' -count=1 -v`
+
+- [ ] **Step 5: The pane fragment**
+
+`templates/_bookingpane.html`:
+```html
+{{define "bookingpane"}}
+{{if .Empty}}<p cal-book-blank>Type when you are free, like <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}}
+```
+Add `"templates/_bookingpane.html"` to the `fragments` ParseFS in `render.go` and to every page's ParseFS list (find where `_quickpane.html` is listed and add beside it).
+
+`bookingPaneFor(text)`: empty text → `Empty: true`; else parse, `Lines = DescribeWindows(ws)`, `Problems = problems`.
+
+- [ ] **Step 6: `make ci`; commit** `booking: one parser for "mondays 12–3", and it is the Go one`.
+
+---
+
+### Task 3: Slot arithmetic
+
+Spec §4.
+
+**Files:**
+- Create: `internal/calendar/slots.go` (pure), `internal/calendar/store_slots.go`
+- Test: `internal/calendartest/slots_test.go`
+
+**Interfaces — Produces:**
+
+```go
+type Slot struct {
+ StartUTC, EndUTC int64
+ Local time.Time // host-zone wall clock of StartUTC
+}
+type Busy struct{ StartUTC, EndUTC int64 }
+type SlotRule struct {
+ DurationMin, BufferMin, NoticeMin, HorizonDays int
+ Zone string
+ Windows []Window
+}
+// ComputeSlots is pure. from/to are UTC instants bounding the days of
+// interest; now applies notice and horizon. Slots are ascending.
+func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot, error)
+
+// BusyFor is every interval that blocks a slot for host in [from, to):
+// EventsInRange(host) with Busy(kind), status != cancelled, tentative
+// INCLUDED, all-day expanded to its DayBounds. excludeEventID (0 = none)
+// is the booking being rescheduled.
+func (s *Store) BusyFor(ctx context.Context, hostID, from, to, 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 ruleFor(page BookingPageView, host Person) SlotRule
+```
+
+- [ ] **Step 1: Failing tests**
+
+```go
+// internal/calendartest/slots_test.go
+package calendartest
+
+import (
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+var dublin = mustZone("Europe/Dublin")
+
+func mustZone(n string) *time.Location { l, err := time.LoadLocation(n); if err != nil { panic(err) }; return l }
+
+func mondayRule(dur, buffer int) calendar.SlotRule {
+ return calendar.SlotRule{DurationMin: dur, BufferMin: buffer, NoticeMin: 0, HorizonDays: 30, Zone: "Europe/Dublin",
+ Windows: []calendar.Window{{Weekday: time.Monday, StartMin: 12 * 60, EndMin: 15 * 60}}}
+}
+
+// Monday 7 September 2026 is a Monday; IST (UTC+1).
+var mon = time.Date(2026, 9, 7, 0, 0, 0, 0, dublin)
+
+func starts(slots []calendar.Slot) []string {
+ var out []string
+ for _, s := range slots { out = append(out, s.Local.Format("Mon 15:04")) }
+ return out
+}
+func eq(t *testing.T, got, want []string) {
+ t.Helper()
+ if len(got) != len(want) { t.Fatalf("got %v, want %v", got, want) }
+ for i := range got { if got[i] != want[i] { t.Fatalf("got %v, want %v", got, want) } }
+}
+
+func TestSlotsStepByDurationInsideTheWindow(t *testing.T) {
+ now := mon.Add(-24 * time.Hour)
+ slots, err := calendar.ComputeSlots(mondayRule(60, 0), nil, now, mon, mon.AddDate(0, 0, 1))
+ if err != nil { t.Fatal(err) }
+ eq(t, starts(slots), []string{"Mon 12:00", "Mon 13:00", "Mon 14:00"})
+}
+
+func TestABusyBlockRemovesTheSlotItOverlaps(t *testing.T) {
+ now := mon.Add(-24 * time.Hour)
+ busy := []calendar.Busy{{StartUTC: mon.Add(13 * time.Hour).Unix(), EndUTC: mon.Add(13*time.Hour + 30*time.Minute).Unix()}}
+ slots, _ := calendar.ComputeSlots(mondayRule(60, 0), busy, now, mon, mon.AddDate(0, 0, 1))
+ eq(t, starts(slots), []string{"Mon 12:00", "Mon 14:00"})
+}
+
+func TestTheBufferPadsEverySlotBeforeTheOverlapTest(t *testing.T) {
+ now := mon.Add(-24 * time.Hour)
+ busy := []calendar.Busy{{StartUTC: mon.Add(13 * time.Hour).Unix(), EndUTC: mon.Add(13*time.Hour + 30*time.Minute).Unix()}}
+ // 12:00 padded is [11:50, 13:10) which touches 13:00–13:30; 14:00 padded is [13:50, 15:10), which does not.
+ slots, _ := calendar.ComputeSlots(mondayRule(60, 10), busy, now, mon, mon.AddDate(0, 0, 1))
+ eq(t, starts(slots), []string{"Mon 14:00"})
+}
+
+func TestNoticeHidesTheNearFutureAndHorizonEndsTheList(t *testing.T) {
+ rule := mondayRule(60, 0)
+ rule.NoticeMin = 120
+ rule.HorizonDays = 7
+ now := mon.Add(11 * time.Hour) // 11:00 on the Monday: 12:00 is inside two hours
+ slots, _ := calendar.ComputeSlots(rule, nil, now, mon, mon.AddDate(0, 0, 14))
+ eq(t, starts(slots), []string{"Mon 13:00", "Mon 14:00"}) // the following Monday is past the 7-day horizon
+}
+
+func TestADSTSundayWindowIsTheWallClockItSays(t *testing.T) {
+ // Spring forward: 29 March 2026, 01:00 → 02:00 in Europe/Dublin.
+ sun := time.Date(2026, 3, 29, 0, 0, 0, 0, dublin)
+ rule := calendar.SlotRule{DurationMin: 60, HorizonDays: 30, Zone: "Europe/Dublin",
+ Windows: []calendar.Window{{Weekday: time.Sunday, StartMin: 0, EndMin: 4 * 60}}}
+ slots, _ := calendar.ComputeSlots(rule, nil, sun.Add(-48*time.Hour), sun, sun.AddDate(0, 0, 1))
+ // 00:00, 02:00, 03:00 — 01:00 does not exist that night. Pinned to real instants.
+ eq(t, starts(slots), []string{"Sun 00:00", "Sun 02:00", "Sun 03:00"})
+ if slots[1].StartUTC != time.Date(2026, 3, 29, 1, 0, 0, 0, time.UTC).Unix() {
+ t.Errorf("02:00 IST is 01:00Z, got %d", slots[1].StartUTC)
+ }
+}
+```
+
+The DST expectation is the one to get RIGHT rather than make pass: build each candidate with `time.Date(y, m, d, hh, mm, 0, 0, loc)` from window minutes, and drop a candidate whose `Local.Hour()*60+Minute()` does not equal the minute you asked for (that is the gap). Whatever Go actually does at 01:00 on that night, the test pins it; if the list Go produces differs from the three above, fix the TEST to the real behaviour and say so in its comment — `clock.go`'s comment explains the precedent.
+
+- [ ] **Step 2: Run, confirm failure.** `go test ./internal/calendartest/ -run 'Slots|Busy|Buffer|Notice|DST' -count=1`
+
+- [ ] **Step 3: 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.AddDate(0, 0, rule.HorizonDays)
+ if from.Before(earliest) { from = earliest }
+ if to.After(latest) { to = latest }
+ byDay := map[time.Weekday][]Window{}
+ for _, w := range rule.Windows { byDay[w.Weekday] = append(byDay[w.Weekday], w) }
+ dur := time.Duration(rule.DurationMin) * time.Minute
+ pad := time.Duration(rule.BufferMin) * time.Minute
+ var out []Slot
+ day := time.Date(from.In(loc).Year(), from.In(loc).Month(), from.In(loc).Day(), 0, 0, 0, 0, loc)
+ for ; day.Before(to); day = day.AddDate(0, 0, 1) { // AddDate on a midnight in loc is the next midnight in loc — a 23/24/25h day
+ 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)
+ if start.Hour()*60+start.Minute() != m { continue } // the DST gap: this wall clock does not exist today
+ end := start.Add(dur)
+ if start.Before(from) || end.After(to) { 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
+}
+func overlapsAny(s, e int64, busy []Busy) bool { for _, b := range busy { if s < b.EndUTC && b.StartUTC < e { return true } }; return false }
+```
+
+`store_slots.go`: `BusyFor` calls `s.EventsInRange(ctx, hostID, from, to)`, skips `!Busy(e.Kind)`, `e.Status == StatusCancelled`, `e.ID == excludeEventID`; for all-day uses `e.OccStartUTC/OccEndUTC` as stored (they are already day bounds in the event's own zone). `FreeSlots` = `ComputeSlots(ruleFor(page, host), busy, now, from, to)` with `busy` fetched over `[from-1day, to+1day)` so buffers at the edges see their neighbours.
+
+- [ ] **Step 4: Run; pass. Step 5: `make ci`; commit** `booking: free slots — the host's windows minus everything the host can see`.
+
+---
+
+### Task 4: The booking state machine in the store
+
+Spec §5 (store half), §7, §8.
+
+**Files:**
+- Create: `internal/calendar/store_booking_flow.go`, `internal/calendar/store_booking_caps.go`
+- Modify: `internal/calendar/store_reminder_next.go:54-64` (`NextDueAt`)
+- Test: `internal/calendartest/booking_store_test.go` (append)
+
+**Interfaces — Produces:**
+
+```go
+const HoldTTL = 30 * time.Minute
+const (
+ MaxOpenHoldsPerIP = 5
+ MaxOpenHoldsPerPage = 20
+ MaxOpenHoldsPerAddress = 2
+)
+var ErrSlotTaken = errors.New("calendar: slot taken")
+var ErrOverCap = errors.New("calendar: too many open holds")
+
+type HoldInput struct {
+ Page BookingPageView
+ Host Person
+ StartUTC, EndUTC int64
+ Name, Address string
+ Note, BookerZone string
+ IPHash string
+}
+type BookingView struct {
+ Booking
+ Page BookingPageView
+ Host Person
+ Event EventView // zero when EventID is nil
+ Name, Address, Note string
+}
+
+// HoldBooking re-checks the slot against FreeSlots INSIDE the write
+// transaction, creates the tentative event on the host's personal
+// calendar and the booking row, and returns the plaintext token once.
+func (s *Store) HoldBooking(ctx context.Context, in HoldInput, now time.Time) (token string, b Booking, err error)
+func (s *Store) DeleteHold(ctx context.Context, bookingID int64) error // hard-delete row + event, for a failed send
+func (s *Store) BookingByToken(ctx context.Context, token string) (BookingView, error) // ErrNotFound
+func (s *Store) ConfirmBooking(ctx context.Context, bookingID int64, now time.Time) (changed bool, err error) // idempotent; held+expired → no change, error nil, changed false
+func (s *Store) RescheduleBooking(ctx context.Context, bookingID, startUTC, endUTC int64, now time.Time) error // confirmed only; re-checks FreeSlots excluding own event; sequence++
+func (s *Store) CancelBooking(ctx context.Context, bookingID int64, now time.Time) error
+func (s *Store) ExpireHolds(ctx context.Context, now time.Time) (int, error) // held & hold_expires_at <= now → expired, event soft-deleted
+func (s *Store) NextHoldExpiry(ctx context.Context) time.Time // zero when none
+func (s *Store) OpenHoldCounts(ctx context.Context, pageID int64, ipHash, addressFP string, now time.Time) (perIP, perPage, perAddress int, err error)
+```
+
+- [ ] **Step 1: Failing tests** (append to `booking_store_test.go`)
+
+```go
+func hostWithPage(t *testing.T) (*calendar.App, calendar.Person, calendar.BookingPageView) {
+ t.Helper()
+ app, _ := signedIn(t, "host@example.com")
+ ctx := context.Background()
+ st := app.StoreForTest()
+ host, _ := st.PersonByAddress(ctx, "host@example.com")
+ _ = st.SaveHandle(ctx, host.ID, "paul")
+ ws, _ := calendar.ParseAvailability("every day 9-5")
+ p, err := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{
+ Slug: "chat", Title: "Chat", Availability: "every day 9-5", DurationMin: 30, BufferMin: 0, NoticeMin: 0, HorizonDays: 30, Active: true}, ws)
+ if err != nil { t.Fatal(err) }
+ page, _ := st.BookingPage(ctx, host.ID, p.ID)
+ return app, host, page
+}
+
+func firstSlot(t *testing.T, app *calendar.App, page calendar.BookingPageView, host calendar.Person, now time.Time) calendar.Slot {
+ t.Helper()
+ slots, err := app.StoreForTest().FreeSlots(context.Background(), page, host, now, now, now.AddDate(0, 0, 3), 0)
+ if err != nil || len(slots) == 0 { t.Fatalf("no free slots: %v", err) }
+ return slots[0]
+}
+
+func TestAHoldIsATentativeEventAndBlocksTheSlot(t *testing.T) {
+ app, host, page := hostWithPage(t)
+ ctx := context.Background()
+ st := app.StoreForTest()
+ now := time.Now()
+ slot := firstSlot(t, app, page, host, now)
+ in := calendar.HoldInput{Page: page, Host: host, StartUTC: slot.StartUTC, EndUTC: slot.EndUTC,
+ Name: "Doc", Address: "doc@tito.io", BookerZone: "Europe/Dublin", IPHash: "ip1"}
+ token, b, err := st.HoldBooking(ctx, in, now)
+ if err != nil || token == "" { t.Fatalf("hold: %v", err) }
+ ev, err := st.Event(ctx, host.ID, *b.EventID)
+ if err != nil { t.Fatal(err) }
+ if ev.Status != calendar.StatusTentative { t.Errorf("status %q, want tentative", ev.Status) }
+ if ev.Title != "Chat — Doc" { t.Errorf("title %q", ev.Title) }
+ // The same slot is gone for the next stranger.
+ if _, _, err := st.HoldBooking(ctx, in, now); err != calendar.ErrSlotTaken {
+ t.Errorf("second hold: err = %v, want ErrSlotTaken", err)
+ }
+ // And the token is not on disk.
+ if found, where := app.GrepDatabaseForTest(t, token); found { t.Errorf("token in %s", where) }
+}
+
+func TestConfirmIsIdempotentAndRecordsTheAttendee(t *testing.T) {
+ app, host, page := hostWithPage(t)
+ ctx := context.Background()
+ st := app.StoreForTest()
+ now := time.Now()
+ slot := firstSlot(t, app, page, host, now)
+ token, b, _ := st.HoldBooking(ctx, calendar.HoldInput{Page: page, Host: host, StartUTC: slot.StartUTC, EndUTC: slot.EndUTC,
+ Name: "Doc", Address: "doc@tito.io", BookerZone: "UTC", IPHash: "ip"}, now)
+ changed, err := st.ConfirmBooking(ctx, b.ID, now)
+ if err != nil || !changed { t.Fatalf("confirm: changed=%v err=%v", changed, err) }
+ changed, err = st.ConfirmBooking(ctx, b.ID, now)
+ if err != nil || changed { t.Errorf("second confirm: changed=%v err=%v, want false,nil", changed, err) }
+ v, _ := st.BookingByToken(ctx, token)
+ if v.State != calendar.BookingConfirmed || v.Event.Status != calendar.StatusConfirmed { t.Errorf("state %q / %q", v.State, v.Event.Status) }
+ att, _ := st.AttendeesFor(ctx, *b.EventID)
+ if len(att) != 1 || att[0] != "doc@tito.io" { t.Errorf("attendees %v", att) }
+}
+
+func TestAnExpiredHoldIsSweptAndTheSlotIsFreeAgain(t *testing.T) {
+ app, host, page := hostWithPage(t)
+ ctx := context.Background()
+ st := app.StoreForTest()
+ now := time.Now()
+ slot := firstSlot(t, app, page, host, now)
+ _, b, _ := st.HoldBooking(ctx, calendar.HoldInput{Page: page, Host: host, StartUTC: slot.StartUTC, EndUTC: slot.EndUTC,
+ Name: "Doc", Address: "doc@tito.io", BookerZone: "UTC", IPHash: "ip"}, now)
+ if exp := st.NextHoldExpiry(ctx); exp.Unix() != b.HoldExpiresAt { t.Errorf("NextHoldExpiry %v, want %d", exp, b.HoldExpiresAt) }
+ later := now.Add(calendar.HoldTTL + time.Minute)
+ n, err := st.ExpireHolds(ctx, later)
+ if err != nil || n != 1 { t.Fatalf("expire: n=%d err=%v", n, err) }
+ if _, err := st.Event(ctx, host.ID, *b.EventID); !calendar.NotFound(err) { t.Errorf("held event still visible: %v", err) }
+ if _, err := st.ConfirmBooking(ctx, b.ID, later); err == nil {
+ // confirm after expiry is a no-op with no error, but the state must be expired
+ }
+ again := firstSlot(t, app, page, host, now)
+ if again.StartUTC != slot.StartUTC { t.Errorf("slot not freed: got %d want %d", again.StartUTC, slot.StartUTC) }
+}
+
+func TestRescheduleMovesTheEventAndBumpsSequence(t *testing.T) {
+ app, host, page := hostWithPage(t)
+ ctx := context.Background()
+ st := app.StoreForTest()
+ now := time.Now()
+ slots, _ := st.FreeSlots(ctx, page, host, now, now, now.AddDate(0, 0, 3), 0)
+ first, second := slots[0], slots[1]
+ token, b, _ := st.HoldBooking(ctx, calendar.HoldInput{Page: page, Host: host, StartUTC: first.StartUTC, EndUTC: first.EndUTC,
+ Name: "Doc", Address: "doc@tito.io", BookerZone: "UTC", IPHash: "ip"}, now)
+ _, _ = st.ConfirmBooking(ctx, b.ID, now)
+ if err := st.RescheduleBooking(ctx, b.ID, second.StartUTC, second.EndUTC, now); err != nil { t.Fatal(err) }
+ v, _ := st.BookingByToken(ctx, token)
+ if v.Event.StartUTC != second.StartUTC { t.Errorf("event did not move") }
+ if v.Sequence != 1 { t.Errorf("sequence %d, want 1", v.Sequence) }
+ // Moving BACK to the original slot must work: the booking's own event is not busy against itself.
+ if err := st.RescheduleBooking(ctx, b.ID, first.StartUTC, first.EndUTC, now); err != nil { t.Errorf("move back: %v", err) }
+}
+
+func TestCapsCountOnlyOpenHolds(t *testing.T) {
+ app, host, page := hostWithPage(t)
+ ctx := context.Background()
+ st := app.StoreForTest()
+ now := time.Now()
+ slots, _ := st.FreeSlots(ctx, page, host, now, now, now.AddDate(0, 0, 5), 0)
+ for i := 0; i < 3; i++ {
+ _, _, err := st.HoldBooking(ctx, calendar.HoldInput{Page: page, Host: host, StartUTC: slots[i].StartUTC, EndUTC: slots[i].EndUTC,
+ Name: "X", Address: fmt.Sprintf("x%d@example.com", i), BookerZone: "UTC", IPHash: "same-ip"}, now)
+ if err != nil { t.Fatal(err) }
+ }
+ perIP, perPage, _, err := st.OpenHoldCounts(ctx, page.ID, "same-ip", "", now)
+ if err != nil || perIP != 3 || perPage != 3 { t.Errorf("counts ip=%d page=%d err=%v", perIP, perPage, err) }
+ _, _ = st.ExpireHolds(ctx, now.Add(calendar.HoldTTL+time.Minute))
+ perIP, _, _, _ = st.OpenHoldCounts(ctx, page.ID, "same-ip", "", now.Add(calendar.HoldTTL+time.Minute))
+ if perIP != 0 { t.Errorf("expired holds still counted: %d", perIP) }
+}
+```
+
+- [ ] **Step 2: Run, confirm compile failure.**
+
+- [ ] **Step 3: Implement**
+
+`HoldBooking`, inside `s.g.WithContext(ctx).Transaction`:
+1. `FreeSlots(ctx, in.Page, in.Host, now, start, end, 0)` for the slot's own day; if no slot has `StartUTC == in.StartUTC` → `ErrSlotTaken`. (SQLite serialises writers, so this check inside the write transaction is the race guard; say so in a comment.)
+2. `calID, _ := s.DefaultCalendarID(ctx, in.Host.ID)`.
+3. `startLocal := time.Unix(in.StartUTC,0).In(hostLoc).Format(LayoutLocal)`, same for end. `buildEvent(in.Host.ID, EventInput{CalendarID: calID, Title: in.Page.Title + " — " + in.Name, Notes: in.Note, StartLocal, EndLocal, TZ: in.Host.Zone, Kind: KindMeeting, Status: StatusTentative})`; `tx.Create(&e)`.
+4. Token: 32 random bytes hex, `TokenHash: HashFeedToken(token)`. Seal name/address/note under the booking purposes; `BookerAddressFP: s.seal.Fingerprint(seal.PurposeAttendeeLookup, normalizeAddress(in.Address))`. `HoldExpiresAt: now.Add(HoldTTL).Unix()`. `tx.Create(&b)`.
+
+`ConfirmBooking`: load row; `held` and `HoldExpiresAt > now` → in one transaction set state/confirmed_at, event `status = confirmed, sequence = sequence + 1, updated_at`, `SetAttendees(eventID, [address])`; return `true`. `confirmed` → `false, nil`. Anything else → `false, nil` (the handler reads the state to decide what to render).
+
+`ExpireHolds`: `UPDATE bookings SET state='expired' WHERE state='held' AND hold_expires_at <= ?` returning the ids first (SELECT then UPDATE in one transaction), then `UPDATE events SET deleted_at = ? WHERE id IN (...)`.
+
+`NextDueAt` in `store_reminder_next.go`: compute the reminder due as now, then `if h := s.NextHoldExpiry(ctx); !h.IsZero() && (due.IsZero() || h.Before(due)) { due = h }`.
+
+`OpenHoldCounts`: three `COUNT(*)` queries with `state = 'held' AND hold_expires_at > ?`.
+
+- [ ] **Step 4: Run; pass. Step 5: `make ci`; commit** `booking: held → confirmed → cancelled, and expired holds free their slot`.
+
+---
+
+### Task 5: Host settings UI
+
+Spec §2.1, §3.4, plus the list/create/edit screens.
+
+**Files:**
+- Create: `internal/calendar/handlers_booking_settings.go`
+- Create: `internal/calendar/templates/booking_pages.html`, `booking_page_form.html`
+- Create: `internal/calendar/static/booking.js`
+- Modify: `internal/calendar/templates/settings.html` (handle field, link to pages), `handlers_misc.go:230-252` (`saveSettings` reads `handle`), `routes.go` (signed-in group), `render.go` (parse new pages), `templates/layout.html` (include `booking.js` — find where `quickadd.js` is included and add beside it), `static/calendar.css`
+- Test: `internal/calendartest/booking_settings_test.go`
+
+**Routes (inside the signed-in group):**
+```
+GET /settings/booking list pages + "New page"
+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}/active toggle (field active=0|1)
+POST /settings/booking/parse fragment "bookingpane"
+```
+
+- [ ] **Step 1: Failing router tests**
+
+```go
+func TestAHostCanMakeAPageAndSeeItsPublicURL(t *testing.T) {
+ app, session := signedIn(t, "host@example.com")
+ h := app.Handler()
+ rec := postAs(t, h, session, "/settings", url.Values{"zone": {"Europe/Dublin"}, "week_start": {"1"},
+ "default_reminder_min": {"10"}, "default_all_day_min": {"360"}, "handle": {"paul"}})
+ if rec.Code != http.StatusSeeOther { t.Fatalf("save settings: %d\n%s", rec.Code, rec.Body.String()) }
+ rec = postAs(t, h, session, "/settings/booking", url.Values{
+ "slug": {"chat"}, "title": {"Quick chat"}, "description": {"Fifteen minutes."},
+ "availability": {"mondays 12-3, tuesdays 12-2, 5-7"}, "duration_min": {"15"},
+ "buffer_min": {"10"}, "notice_min": {"120"}, "horizon_days": {"30"}, "active": {"1"}})
+ if rec.Code != http.StatusSeeOther { t.Fatalf("create page: %d\n%s", rec.Code, rec.Body.String()) }
+ body := getAs(t, h, session, "/settings/booking").Body.String()
+ for _, want := range []string{"Quick chat", "/book/paul/chat", "Tuesday 12:00–14:00, 17:00–19:00"} {
+ if !strings.Contains(body, want) { t.Errorf("list lacks %q:\n%s", want, body) }
+ }
+}
+
+func TestAPageWithAnUnreadableSentenceIsNotSaved(t *testing.T) {
+ app, session := signedIn(t, "host@example.com")
+ h := app.Handler()
+ postAs(t, h, session, "/settings", url.Values{"zone": {"UTC"}, "handle": {"paul"}})
+ rec := postAs(t, h, session, "/settings/booking", url.Values{
+ "slug": {"chat"}, "title": {"Chat"}, "availability": {"mondays whenever"}, "duration_min": {"30"}, "horizon_days": {"30"}})
+ if rec.Code != http.StatusUnprocessableEntity { t.Fatalf("status %d, want 422", rec.Code) }
+ if !strings.Contains(rec.Body.String(), "whenever") { t.Error("the refusal does not quote the clause") }
+ if strings.Contains(getAs(t, h, session, "/settings/booking").Body.String(), "/book/paul/chat") {
+ t.Error("the page was saved anyway")
+ }
+}
+
+func TestThePaneFragmentDescribesTheWindows(t *testing.T) {
+ app, session := signedIn(t, "host@example.com")
+ rec := postAs(t, app.Handler(), session, "/settings/booking/parse", url.Values{"text": {"fri 9-5"}})
+ if !strings.Contains(rec.Body.String(), "Friday 09:00–17:00") || strings.Contains(strings.ToLower(rec.Body.String()), "<html") {
+ t.Errorf("pane:\n%s", rec.Body.String())
+ }
+}
+
+func TestAnotherPersonsBookingPageIsNotFound(t *testing.T) {
+ app, mine := signedIn(t, "me@example.com")
+ h := app.Handler()
+ postAs(t, h, mine, "/settings", url.Values{"zone": {"UTC"}, "handle": {"me"}})
+ postAs(t, h, mine, "/settings/booking", url.Values{"slug": {"x"}, "title": {"X"}, "availability": {"mon 9-5"}, "duration_min": {"30"}, "horizon_days": {"30"}, "active": {"1"}})
+ theirs := app.SignInForTest(t, "them@example.com")
+ if rec := getAs(t, h, theirs, "/settings/booking/1"); rec.Code != http.StatusNotFound { t.Errorf("status %d, want 404", rec.Code) }
+}
+```
+
+Note: `saveSettings` currently requires `week_start` etc.; check `SaveSettings` validation and pass whatever fields it needs so the handle test isolates the handle.
+
+- [ ] **Step 2: Run; confirm 404s/compile failures.**
+
+- [ ] **Step 3: Implement handlers**
+
+`saveSettings`: after `SaveSettings`, `if err := h.store.SaveHandle(ctx, p.ID, r.FormValue("handle")); err != nil` → `ErrHandleTaken` renders the settings page with `Error: "That handle is taken."` at 422; a `ValidationError` renders its message.
+
+`bookingPageForm` struct: every field as string plus `Pane BookingPaneView`, `Error string`, `ID int64`, `PublicURL string`. `readBookingPageForm(r) (BookingPageInput, form)`: `strconv.Atoi` each number; defaults 10/120/30 when blank. `createBookingPage`: `ws, problems := ParseAvailability(in.Availability)`; problems non-empty → 422 with the form and pane; else `CreateBookingPage`; `ValidationError` → 422 too. Same for update.
+
+List page shows for each: title, duration, `Active` as "Live"/"Paused", the public URL `h.origin + "/book/" + handle + "/" + slug` (only when the person has a handle; otherwise a callout "Choose a handle in Settings to publish these"), `DescribeWindows` lines, Edit link, an active toggle form.
+
+Templates: use `field-input`, `field-textarea`, `field-select`, `field-check`, `form-foot`, `callout` from rastrillo's partials (see `settings.html` for the `dict` calling convention; check `field-input.html` and `field-textarea.html` in the module cache for exact keys). The availability textarea gets `cal-book-text` and the pane `<div id="cal-book-pane" cal-book-pane aria-live="polite">{{template "bookingpane" .Data.Pane}}</div>` directly under it.
+
+`booking.js`: copy `quickadd.js`, root selector `[cal-book-form]`, text `[cal-book-text]`, pane `[cal-book-pane]`, endpoint `/settings/booking/parse`, no Enter handler, no hint. Keep it under 100 lines. It also does the public page's zone preselect (Task 6) — write that as a second `setup` in the same file, guarded by `[cal-book-zone]`.
+
+CSS: a `cal-book*` block in `calendar.css` reusing the `cal-quick-pane` look (tokens only from `var(--rst-*)` that already exist in the file).
+
+- [ ] **Step 4: Run; pass. Step 5: `make ci`; commit** `booking: the host's side — a handle, pages, and a pane that says what "mondays 12–3" means`.
+
+---
+
+### Task 6: The public flow, mail and ICS
+
+Spec §5, §6.
+
+**Files:**
+- Create: `internal/calendar/handlers_booking_public.go`, `handlers_booking_manage.go`, `booking_mail.go`, `booking_ics.go`
+- Create: `internal/calendar/templates/book_page.html`, `book_sent.html`, `book_manage.html`, `book_gone.html`, `templates/mail/booking_hold.txt`, `booking_confirmed_host.txt`, `booking_rescheduled.txt`, `booking_cancelled.txt`
+- Modify: `routes.go` (public routes beside `/feed`), `render.go`, `static/booking.js` (zone preselect), `static/calendar.css`
+- Test: `internal/calendartest/booking_http_test.go`
+
+**Routes (in the chi router, OUTSIDE the session group, AFTER `/feed`):**
+```
+GET /book/{handle}/{slug} page; ?day=YYYY-MM-DD&zone=Area/City
+POST /book/{handle}/{slug} hold: start (unix), zone, name, address, note
+GET /book/m/{token} confirm-if-held, then manage
+GET /book/m/{token}/calendar.ics the ICS (a path segment, not {token}.ics, so chi cannot confuse it with the manage route)
+GET /book/m/{token}/reschedule slot picker (?day=&zone=)
+POST /book/m/{token}/reschedule start
+POST /book/m/{token}/cancel
+```
+Register `/book/m/...` BEFORE `/book/{handle}/{slug}`; `ValidateHandle` already refuses `m`.
+
+**Client IP for the cap:** `func clientIP(r *http.Request) string` — first comma-separated value of `X-Forwarded-For` if present, else host of `r.RemoteAddr`. Fingerprint it with `s.seal.Fingerprint(seal.PurposeBookingIP, ip)`. Never log it.
+
+**Mail (`booking_mail.go`):** `//go:embed templates/mail/*.txt` into a `text/template` set. Four funcs on `handlers`: `mailHold(ctx, v BookingView, token string) error`, `mailConfirmedToHost`, `mailRescheduled(both)`, `mailCancelled(both)`. Links are `h.origin + "/book/m/" + token`. Times formatted with `InZone(unix, zone).Format("Monday 2 January 2006, 15:04")` plus the zone name. The host's mail also links `h.origin + "/events/" + eventID`.
+
+**ICS (`booking_ics.go`):** `func WriteBookingICS(v BookingView, manageURL string) string` — VCALENDAR with `METHOD:REQUEST` (confirmed) or `METHOD:CANCEL` (cancelled); one VEVENT via `line(...)`/`escapeText`/`icsDateTime`/`utcStamp` from `ics_export.go`: `UID` = the event's opened UID (`v.Event.UIDSealedOpen`; load it — see how `EventsForFeed` does), `SEQUENCE` = `v.Sequence`, `DTSTART;TZID=`/`DTEND;TZID=` in the host zone, `SUMMARY` = page title, `DESCRIPTION` = page description, `ORGANIZER;CN=<host name or handle>:mailto:<host address>`, `ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:<booker>`, `URL:` manage URL, `STATUS:CONFIRMED|CANCELLED`.
+
+- [ ] **Step 1: Failing router tests**
+
+```go
+// internal/calendartest/booking_http_test.go
+package calendartest
+
+func publishedPage(t *testing.T) (*calendar.App, *pickyMailer, string) {
+ t.Helper()
+ mailer := &pickyMailer{}
+ app := newAppWithMailer(t, mailer)
+ session := app.SignInForTest(t, "host@example.com")
+ h := app.Handler()
+ postAs(t, h, session, "/settings", url.Values{"zone": {"Europe/Dublin"}, "handle": {"paul"}})
+ postAs(t, h, session, "/settings/booking", url.Values{"slug": {"chat"}, "title": {"Quick chat"},
+ "availability": {"every day 9-5"}, "duration_min": {"30"}, "buffer_min": {"0"}, "notice_min": {"0"}, "horizon_days": {"30"}, "active": {"1"}})
+ mailer.delivered = nil
+ return app, mailer, "/book/paul/chat"
+}
+
+// A stranger has no session. RequestForTest(nil, ...) sends Origin on POST, as a browser would from our own page.
+func strangerGet(t *testing.T, h http.Handler, target string) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder(); h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodGet, target, nil)); return rec
+}
+func strangerPost(t *testing.T, h http.Handler, target string, form url.Values) *httptest.ResponseRecorder {
+ rec := httptest.NewRecorder(); h.ServeHTTP(rec, calendar.RequestForTest(nil, http.MethodPost, target, form)); return rec
+}
+
+var reStart = regexp.MustCompile(`name="start" value="(\d+)"`)
+var reManage = regexp.MustCompile(`/book/m/([0-9a-f]{64})`)
+
+func TestAStrangerCanHoldConfirmAndSeeTheICS(t *testing.T) {
+ app, mailer, path := publishedPage(t)
+ h := app.Handler()
+ day := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
+ page := strangerGet(t, h, path+"?day="+day+"&zone=Europe/Dublin")
+ if page.Code != 200 { t.Fatalf("page: %d", page.Code) }
+ m := reStart.FindStringSubmatch(page.Body.String())
+ if m == nil { t.Fatalf("no slot buttons:\n%s", page.Body.String()) }
+ rec := strangerPost(t, h, path, url.Values{"start": {m[1]}, "zone": {"Europe/Dublin"}, "name": {"Doc"}, "address": {"doc@tito.io"}, "note": {"About the thing"}})
+ if rec.Code != 200 || !strings.Contains(rec.Body.String(), "doc@tito.io") { t.Fatalf("hold: %d\n%s", rec.Code, rec.Body.String()) }
+ if len(mailer.delivered) != 1 { t.Fatalf("mails %d, want 1 (to the booker)", len(mailer.delivered)) }
+ link := reManage.FindString(mailer.delivered[0])
+ if link == "" { t.Fatalf("no manage link in:\n%s", mailer.delivered[0]) }
+ // The link confirms.
+ man := strangerGet(t, h, link)
+ if man.Code != 200 || !strings.Contains(man.Body.String(), "Reschedule") { t.Fatalf("manage: %d\n%s", man.Code, man.Body.String()) }
+ if len(mailer.delivered) != 2 || !strings.Contains(mailer.delivered[1], "Doc") { t.Errorf("host was not told: %v", mailer.delivered) }
+ // Idempotent.
+ strangerGet(t, h, link)
+ if len(mailer.delivered) != 2 { t.Errorf("confirming twice mailed the host twice") }
+ // The ICS.
+ ics := strangerGet(t, h, link+"/calendar.ics")
+ body := ics.Body.String()
+ for _, want := range []string{"METHOD:REQUEST", "SUMMARY:Quick chat", "ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:doc@tito.io", "ORGANIZER", "mailto:host@example.com", "URL:", "SEQUENCE:"} {
+ if !strings.Contains(body, want) { t.Errorf("ics lacks %q:\n%s", want, body) }
+ }
+ if ct := ics.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") { t.Errorf("content-type %q", ct) }
+ if _, err := calendar.ParseICS(strings.NewReader(body)); err != nil { t.Errorf("our own parser refuses it: %v", err) } // adapt to ParseICS's real signature
+}
+
+func TestTheSameSlotCannotBeHeldTwice(t *testing.T) {
+ app, _, path := publishedPage(t)
+ h := app.Handler()
+ day := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
+ m := reStart.FindStringSubmatch(strangerGet(t, h, path+"?day="+day).Body.String())
+ form := url.Values{"start": {m[1]}, "zone": {"UTC"}, "name": {"A"}, "address": {"a@example.com"}}
+ strangerPost(t, h, path, form)
+ form.Set("address", "b@example.com")
+ rec := strangerPost(t, h, path, form)
+ if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "just gone") { t.Errorf("second hold: %d\n%s", rec.Code, rec.Body.String()) }
+}
+
+func TestAWrongTokenIsTheSame404AsAnUnknownRoute(t *testing.T) {
+ app, _, _ := publishedPage(t)
+ h := app.Handler()
+ a := strangerGet(t, h, "/book/m/"+strings.Repeat("0", 64))
+ b := strangerGet(t, h, "/no/such/route")
+ if a.Code != 404 || a.Body.String() != b.Body.String() { t.Errorf("token 404 differs from route 404 (%d)", a.Code) }
+}
+
+func TestRescheduleAndCancelTellBothParties(t *testing.T) {
+ app, mailer, path := publishedPage(t)
+ h := app.Handler()
+ day := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
+ all := reStart.FindAllStringSubmatch(strangerGet(t, h, path+"?day="+day).Body.String(), -1)
+ strangerPost(t, h, path, url.Values{"start": {all[0][1]}, "zone": {"UTC"}, "name": {"Doc"}, "address": {"doc@tito.io"}})
+ link := reManage.FindString(mailer.delivered[0])
+ strangerGet(t, h, link)
+ mailer.delivered = nil
+ rec := strangerPost(t, h, link+"/reschedule", url.Values{"start": {all[1][1]}, "zone": {"UTC"}})
+ if rec.Code != 200 { t.Fatalf("reschedule: %d\n%s", rec.Code, rec.Body.String()) }
+ if len(mailer.delivered) != 2 { t.Errorf("reschedule mailed %d, want booker and host", len(mailer.delivered)) }
+ if !strings.Contains(strangerGet(t, h, link+"/calendar.ics").Body.String(), "SEQUENCE:1") { t.Error("SEQUENCE not bumped") }
+ mailer.delivered = nil
+ rec = strangerPost(t, h, link+"/cancel", nil)
+ if rec.Code != 200 || len(mailer.delivered) != 2 { t.Errorf("cancel: %d, mails %d", rec.Code, len(mailer.delivered)) }
+ if !strings.Contains(strangerGet(t, h, link+"/calendar.ics").Body.String(), "METHOD:CANCEL") { t.Error("no METHOD:CANCEL") }
+}
+
+func TestAHoldWhoseMailFailsIsRolledBack(t *testing.T) {
+ app, mailer, path := publishedPage(t)
+ mailer.refuse = "bounce@example.com"
+ h := app.Handler()
+ day := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
+ m := reStart.FindStringSubmatch(strangerGet(t, h, path+"?day="+day).Body.String())
+ rec := strangerPost(t, h, path, url.Values{"start": {m[1]}, "zone": {"UTC"}, "name": {"B"}, "address": {"bounce@example.com"}})
+ if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), "could not send") { t.Errorf("%d\n%s", rec.Code, rec.Body.String()) }
+ // The slot is still free.
+ again := reStart.FindStringSubmatch(strangerGet(t, h, path+"?day="+day).Body.String())
+ if again == nil || again[1] != m[1] { t.Error("the failed hold kept the slot") }
+}
+```
+
+`pickyMailer.refuse` matches on `subject+body`; the hold mail body must contain the address (it does: "You asked to book … we will send to …" — or match on the `to` argument: extend `pickyMailer.Send` to also check `to`, it is test code).
+
+- [ ] **Step 2: Run, confirm failures.**
+
+- [ ] **Step 3: Implement**
+
+`bookPage` (GET): `PublicBookingPage` → 404 on error. Zone from `?zone=` if `Zone()` accepts it, else host's. `?day=` parsed with `ParseDate(day, hostZone)`; when absent, list the next `horizon_days` days that have at least one slot (call `FreeSlots` once for the whole horizon and group by host-zone date) as links. With a day: that day's slots as `<form method="post"><input type="hidden" name="start" value="{{.StartUTC}}"><input type="hidden" name="zone" …><button rst-btn="secondary">{{booker-zone time}}</button></form>` each, followed by the name/address/note form which is shown after a slot is chosen — simplest scriptless shape: the slot button posts `start` and the handler re-renders the page with the chosen slot fixed and the details form visible (`?start=` in the redirect, or render directly at 200). Then the details form posts `start`, `zone`, `name`, `address`, `note`. Two POSTs, no JS.
+
+`bookHold` (POST with `name` present): validate `name` (1–100), `address` (`reAddress` full match), `note` ≤ 2000; recompute slot; `OpenHoldCounts` vs the three caps → 429 `book_gone` with the cap message; `HoldBooking` → `ErrSlotTaken` → 409 re-render with "That time has just gone"; `mailHold` fails → `DeleteHold` → 422 "We could not send to that address"; else 200 `book_sent`.
+
+`bookManage` (GET `/book/m/{token}`): `BookingByToken` → 404. `held && !expired` → `ConfirmBooking`; if `changed` → `mailConfirmedToHost`. Render by state.
+
+`bookICS`: confirmed or cancelled only; held/expired → 404. Headers `Content-Type: text/calendar; charset=utf-8`, `Content-Disposition: attachment; filename="booking.ics"`, `Cache-Control: no-store`.
+
+Reschedule GET: same picker as `bookPage` with `excludeEventID = *v.EventID`. POST: `RescheduleBooking` → `ErrSlotTaken` → 409; else mail both, 200 manage.
+
+Cancel POST: `CancelBooking`, mail both, 200 manage (state cancelled).
+
+Public pages render through `render(w, name, h.page(r, title, data))` — `page` sets `SignedIn=false` for a stranger and the layout already hides the nav (see `layout.html:73`).
+
+- [ ] **Step 4: Run; pass. Step 5: `make ci`; commit** `booking: a stranger holds a slot, a link confirms it, and the ICS is one click away`.
+
+---
+
+### Task 7: Sweep on tick, caps through the router, leak test, browser passes
+
+Spec §7, §8, §9.
+
+**Files:**
+- Modify: `internal/calendar/handlers_jobs.go` (`drain` calls `ExpireHolds` first; `drainOpportunistically` too)
+- Modify: `internal/calendartest/events_test.go` (leak fixture), new `booking_sweep_test.go`
+- Create: `test/browser/booking.mjs`; modify `internal/calendartest/playwright_test.go` to run it after `quickadd.mjs`
+- Modify: `Makefile` if the browser target names the script
+
+- [ ] **Step 1: Failing tests**
+
+```go
+func TestTheTickExpiresHoldsAndFreesTheSlot(t *testing.T) {
+ app, mailer, path := publishedPage(t)
+ _ = mailer
+ h := app.Handler()
+ day := time.Now().AddDate(0, 0, 1).Format("2006-01-02")
+ m := reStart.FindStringSubmatch(strangerGet(t, h, path+"?day="+day).Body.String())
+ strangerPost(t, h, path, url.Values{"start": {m[1]}, "zone": {"UTC"}, "name": {"A"}, "address": {"a@example.com"}})
+ // Age the hold in the database rather than sleeping thirty minutes.
+ if _, err := app.DBForTest().Exec("UPDATE bookings SET hold_expires_at = ?", time.Now().Add(-time.Minute).Unix()); err != nil { t.Fatal(err) }
+ if !app.NextDue().Before(time.Now()) { t.Error("NextDue does not see the expired hold") }
+ if rec := tick(t, h); rec.Code != http.StatusNoContent { t.Fatalf("tick: %d", rec.Code) }
+ again := reStart.FindStringSubmatch(strangerGet(t, h, path+"?day="+day).Body.String())
+ if again == nil || again[1] != m[1] { t.Error("the slot did not come back") }
+}
+
+func TestTheSixthHoldFromOneAddressIs429(t *testing.T) { /* six holds, all zone UTC, from the same RemoteAddr (httptest default 192.0.2.1); the sixth → 429 */ }
+```
+
+Extend `TestTheDatabaseNeverHoldsAPlaintextTitle` (or add `TestTheDatabaseNeverHoldsABooker`): make a page titled `"Interrogation about the acquisition"` with availability `"mondays 12-3"`, hold with name `"Dermot Lacey"`, address `"dermot.lacey@elsewhere.example"`, note `"Bring the folder"`, checkpoint, grep for all five strings and the token.
+
+Log test: with `newAppWithMailer` and a `slog` handler writing to a `bytes.Buffer` (add a `Logger` parameter path in the helper or construct `calendar.New` directly), force a mail failure and assert the buffer does not contain the address.
+
+- [ ] **Step 2: Implement the sweep**: at the top of `drain`, `if n, err := h.store.ExpireHolds(ctx, now); err != nil { return 0, err } else if n > 0 { h.logger.Info("calendar: holds expired", "n", n) }`.
+
+- [ ] **Step 3: Browser passes** — `test/browser/booking.mjs`, same argv contract as `quickadd.mjs`, plus a fourth arg: the public path. Pass A (JS off): open the page, click a day, click a slot, fill name/address, submit, assert "Check your mail". Pass B (JS on): open `/settings/booking/new`, type `tues 12-2`, wait for `[cal-book-pane]` to contain "Tuesday 12:00–14:00". The Go driver creates the host, handle and page through the store before launching node, and passes the path. Read `playwright_test.go` for how the origin and cookie are handed over.
+
+- [ ] **Step 4: `make ci` and `make browser` green; commit** `booking: holds expire on the tick, caps hold, and a stranger with no script can book`.
+
+---
+
+### Task 8: AGENTS.md
+
+Spec §11 verbatim. Add the five rulings as rows in Settled decisions with "Booking spec §1, 2026-09-07" as the who/when; the Frontend, Data and Ticks bullets; the leak-list item 5. Commit `docs: the rules a booking page adds`.
+
+---
+
+## Self-review
+
+- **Spec coverage:** §2 → T1; §3 → T2, T5; §4 → T3; §5 → T4, T6; §6 → T6; §7 → T4 (counts), T6 (429), T7 (test); §8 → T4, T7; §9 → every task's Step 1 plus T7; §10 is scope-out; §11 → T8. §5.1's "host's first name if set" — open `NameSealed` with `PurposeMemberName` in `PublicBookingPage`; if empty, show the handle.
+- **Type consistency:** `Window` declared once in `models_booking.go` and used by `avail.go`, `slots.go`, `store_booking_page.go`. `BookingPageView.Windows []Window`. `Slot.Local` is host zone. `HoldInput.Page` is a `BookingPageView` so `HoldBooking` can call `FreeSlots` without a second read.
+- **Placeholders:** none; where a test's expected value depends on Go's DST behaviour the plan says to pin the real number and explain it, which is the existing convention in `clock_test.go`.
diff --git a/docs/superpowers/specs/2026-09-07-booking-design.md b/docs/superpowers/specs/2026-09-07-booking-design.md
index 0871f9b..6d093f7 100644
--- a/docs/superpowers/specs/2026-09-07-booking-design.md
+++ b/docs/superpowers/specs/2026-09-07-booking-design.md
@@ -3,8 +3,9 @@
**Status:** specified 2026-09-07, before the code. Authored by Claude
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). Reviewed by
-codex — see §12 for what it found and what changed.
+can see counts as busy; pages are configurable per person). **Revision
+2** after a review by GPT-6 through `codex-cli 0.153.0` returned
+REWRITE with 21 findings; §12 records each and what changed.
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
@@ -20,15 +21,16 @@ AGENTS.md, AGENTS.md is wrong and is fixed in the same commit (§11).
## 0. The shape in one paragraph
Each person owns any number of **booking pages**, made in Settings.
-A page has a slug, a duration, an availability sentence, and three knobs
-with defaults. It is public at `/book/{handle}/{slug}`. A stranger picks
-a slot in their own zone, types a name and address, and the slot is
-**held** as a tentative event on the host's personal calendar. One mail
-goes to the booker with one link. Clicking it **confirms**: the event
-becomes confirmed, the booker becomes an attendee, the host is told, and
-the same link is where the booker downloads the ICS, reschedules, or
-cancels. Unclaimed holds expire in thirty minutes and are swept on the
-existing tick.
+A page has a title, a duration, an availability sentence, and three
+knobs with defaults. It is public at `/book/{handle}/{code}`, where the
+code is generated, not chosen. A stranger picks a slot in their own
+zone, types a name and address, and the slot is **held** as a tentative
+event on the host's personal calendar. One mail goes to the booker with
+one link. The link shows a page with one button; pressing it
+**confirms**: the event becomes confirmed, the booker becomes an
+attendee, the host is told, and the same page is where the booker
+downloads the ICS, reschedules, or cancels. Unclaimed holds expire in
+thirty minutes and are swept on the existing tick.
---
@@ -36,53 +38,57 @@ existing tick.
| # | Ruling | Rejected | Why |
|---|---|---|---|
-| B1 | **A booking is confirmed by a link in the booker's mail**, never on submit | Instant confirmation (Calendly's default) | Any typed address would fill a calendar, and the reschedule link would go to whoever that address really is. The link proves the mailbox and is the same one the booker keeps. |
+| B1 | **A booking is confirmed by a button on the page the mailed link opens** — a same-origin POST — never on submit and never on GET | Instant confirmation; confirming on the GET itself | Any typed address would fill a calendar. And a GET that confirms is confirmed by every link-scanning mail gateway on earth, so a stranger typing YOUR address would book you a meeting you never saw (review finding 2). The mailed link is a request; the button is the consent. |
| B2 | **Everything the host can see is busy** — every calendar they hold a grant on | Personal only; a per-page checklist | Nobody gets booked over the team standup. The page reveals only that a slot is gone, never by what. |
| B3 | **Pages are per person, any number** | One page per person | "15 min chat" and "60 min call" are different pages with different windows. |
-| B4 | **The public identifier is a chosen plaintext `handle`** | Address in the URL; sealed name | The address must never appear in a URL. AGENTS.md already gives up "who is on the team", so a handle leaks nothing new. |
+| B4 | **The public identifier is a chosen plaintext `handle` plus a GENERATED page code** | A chosen slug per page | A slug is prose: `redundancy-consultation` in a plaintext column, joined to events through `bookings.page_id`, says what those events ARE. The one rule forbids that. The handle is "who is on the team", which AGENTS.md already gives up. (Finding 1.) |
| B5 | **Booking mail is the ONE outbound to a non-team address, and only to an address the recipient typed in order to receive it** | Emailing quick-add attendees "while we're at it" | Quick-add attendees stay recorded-not-invited. This is a different act: the booker asked for the mail. |
-| B6 | **The ICS is a tokenised URL, not an attachment** | An attachment | `mail.Sender` is plain text and has no attachment path. And a URL is better: a reschedule updates it in place, an attachment goes stale in the inbox. |
+| B6 | **The ICS is a download at a tokenised URL, not an attachment, and carries NO management link** | An attachment; `URL:` pointing at the manage page | `mail.Sender` is plain text. And a management capability inside an ICS is exported to wherever that calendar syncs — a shared team view, a phone, a second export. Delegating write authority is not the same as disclosing contents. (Finding 6.) |
| B7 | **The availability text has ONE parser and it is Go** | A JS parser for the live pane | The standing rule. The pane is a fetch to the server, exactly as quick add. |
-| B8 | **A held slot is a tentative EVENT**, not a row in a side table | A holds table consulted by the slot query | The range query already knows how to subtract events. Two strangers cannot hold one slot because the first hold is already busy. And the host sees the hold on their calendar, labelled, rather than being surprised by a confirmation. |
-| B9 | **Slots are computed in the HOST's zone and rendered in the booker's** | Computing in the booker's zone | The windows are the host's working hours. "Mondays 12–3" is 12–3 where the host is, whoever is looking. |
-| B10 | **Unknown token → 404**, never 403, never "expired" | Distinguishing expired from wrong | Status codes cannot enumerate bookings. An expired hold's manage page says "this hold has expired" only when the token was RIGHT. |
+| B8 | **A held slot is a tentative EVENT**, not a row in a side table | A holds table consulted by the slot query | The range query already knows how to subtract events. The host sees the hold on their calendar, labelled, rather than being surprised by a confirmation. |
+| B9 | **Slots are computed in the HOST's zone and rendered in the booker's** | Computing in the booker's zone | The windows are the host's working hours. |
+| B10 | **Unknown token → 404**, never 403, never "expired" | Distinguishing expired from wrong | Status codes cannot enumerate bookings. An expired hold's page says "expired" only when the token was RIGHT. |
+| B11 | **The EVENT's `sequence` is the one revision counter**; the booking has none | A booking-side sequence | Two counters for one meeting drift the first time the host edits it (finding 11). `UpdateEvent` already bumps `sequence`; every booking transition goes through the same column. |
+| B12 | **Hold creation is serialised by a process-wide mutex**, and the availability check runs inside it | Relying on the write transaction | SQLite begins deferred; two transactions both read "free" before either takes the write lock (finding 9). One binary, one process, one instance: a mutex is the honest serialisation, and a concurrent test proves it. |
+| B13 | **Mail is sent AFTER the row commits, and an unsent notification is a column the drain retries** | Rolling back rows when a send fails | SMTP can accept and then time out; deleting the row leaves a delivered link that 404s (finding 12). A committed row plus a `pending_mail` marker is at-least-once, like reminders. |
---
## 2. Data
-Four additions. All migrations additive.
+All migrations additive, generated by `rastrillo migration generate`.
### 2.1 `people.handle`
```
-handle TEXT UNIQUE -- nullable; a person with no handle has no pages
+handle TEXT UNIQUE -- nullable; a person with no handle has no public pages
```
Plaintext. Lowercase `[a-z0-9-]{2,32}`, chosen on the settings page,
-validated on save, unique. Changing it changes every public URL and the
-settings page says so before the field.
+validated on save, unique. Reserved: `m`, `new`, `static`. Changing it
+changes every public URL and the settings page says so before the field.
### 2.2 `booking_pages`
```
-id, person_id (idx), slug, title_sealed, description_sealed,
+id, person_id (idx), code, title_sealed, description_sealed,
duration_min, buffer_min, notice_min, horizon_days,
-availability_text_sealed, active, created_at, updated_at
-UNIQUE (person_id, slug)
+availability_text_sealed, published_at (nullable), created_at, updated_at
+UNIQUE (person_id, code)
```
-- `slug`: `[a-z0-9-]{1,40}`, plaintext, part of the URL.
-- `title_sealed`, `description_sealed`: content. The title is what the
- booker sees as the page heading and what the event on the host's
- calendar is called (§5.1).
+- `code`: eight characters from `[a-z0-9]`, generated at creation, never
+ editable, plaintext because it is random. The public URL is
+ `/book/{handle}/{code}`.
+- `title_sealed`, `description_sealed`: content. The title heads the
+ public page and names the event on the host's calendar.
- `duration_min`: 5–480. `buffer_min`: 0–120, default 10. `notice_min`:
0–10080, default 120. `horizon_days`: 1–90, default 30.
-- `availability_text_sealed`: the sentence the host typed, verbatim,
- sealed because it is prose and prose may say anything. It is re-parsed
- when the host edits it; the parsed windows are §2.3.
-- `active`: an inactive page 404s publicly and lists as "paused" in
- settings.
+- `availability_text_sealed`: the sentence, verbatim, sealed because it
+ is prose. Re-parsed on every edit into §2.3.
+- `published_at`: NULL means unpublished. An unpublished page 404s
+ publicly. Publishing is an explicit action with its own sentence
+ (§7.3).
### 2.3 `booking_windows`
@@ -90,38 +96,57 @@ UNIQUE (person_id, slug)
id, page_id (idx), weekday (0=Sunday..6), start_min, end_min
```
-Plaintext, deliberately. A window is shape-of-time — when the host is
-free — which is already on the leak list in AGENTS.md ("when they are
-busy, for how long, how often"). `start_min < end_min`, both `0..1440`.
-Rewritten wholesale on every save of the page; never edited in place.
+Plaintext, deliberately: shape-of-time, already on the leak list.
+`0 <= start_min < end_min <= 1440`. Rewritten wholesale on every save.
+**Normalised before storage:** windows on one weekday that overlap or
+touch are merged, so `Monday 9–12, Monday 10–13` is stored as one
+`Monday 9–13` window and steps as one (finding 17).
### 2.4 `bookings`
```
-id, page_id (idx), event_id (idx, nullable after cancel-and-purge),
-token_hash UNIQUE, state,
+id, page_id (idx), event_id (idx), token_hash UNIQUE, state (idx),
booker_name_sealed, booker_address_sealed, booker_address_fp (idx),
note_sealed, booker_zone,
-hold_expires_at, confirmed_at, cancelled_at, sequence,
-created_ip_hash, created_at, updated_at
+hold_expires_at (idx), confirmed_at, cancelled_at,
+pending_mail, mail_attempts,
+ip_fp (idx, nullable), reschedules,
+created_at, updated_at
```
-- `token_hash`: SHA-256 of a 32-byte random token, exactly as
- `HashFeedToken`. The token is 256 bits of entropy so an unkeyed hash is
- fine here; the ADDRESS is not (below).
-- `state`: `held` → `confirmed` → `cancelled`; or `held` → `expired`.
+- `token_hash`: SHA-256 of 32 random bytes, exactly `HashFeedToken`.
+- `state`: one of `held`, `confirmed`, `cancelled`, `expired`, `failed`.
+ The transition table is §5.7.
- `booker_name_sealed`, `booker_address_sealed`, `note_sealed`: content.
- Who the team meets with is exactly what the one rule keeps out of the
- bucket.
- `booker_address_fp`: `seal.Fingerprint(PurposeAttendeeLookup, addr)`,
- the same KEYED fingerprint attendees use, so one booker can be found
- across bookings without their address being a plain digest anyone
- could reverse from a wordlist.
-- `booker_zone`: IANA name, plaintext — it is a zone, not a place.
-- `sequence`: the ICS `SEQUENCE`, bumped on every reschedule and on
- cancel.
-- `created_ip_hash`: keyed fingerprint of the requesting IP under a new
- purpose `PurposeBookingIP`, for the per-IP cap (§7). Never the IP.
+ keyed, as attendees.
+- `booker_zone`: IANA name, plaintext, and ONLY ever one of
+ `CommonZones` (§5.1).
+- `pending_mail`: `''`, `hold`, `confirmed`, `rescheduled`, `cancelled`
+ — WHICH mail is owed, never its content. `mail_attempts` counts tries;
+ after `MaxReminderAttempts` the marker clears and the failure is
+ logged with a stage, not an address.
+- `ip_fp`: `seal.Fingerprint(PurposeBookingIP, ip)` of the client IP at
+ hold time, for the caps in §7. **Nulled by the sweep 24 hours after
+ `created_at`**, because its purpose ends there and a permanent
+ network-linkage column is a leak the estate does not otherwise have
+ (finding 4).
+- `reschedules`: how many times this booking has been moved; capped
+ (§7).
+- **No `sequence`** (B11).
+
+### 2.5 The client IP
+
+The instance listens on a unix socket that only the platform edge can
+reach, and the edge calls `httputil.ProxyRequest.SetXForwarded`, which
+REPLACES `X-Forwarded-For` with the dialling client (`platform/
+internal/edge/homemap.go setForwarded`; on a verified mesh hop the home
+edge restores the original single-element chain). So: **the client IP
+is the LAST element of `X-Forwarded-For`**, and when the header is
+absent (tests, `rastrillo dev`) it is the host part of `RemoteAddr`.
+Canonicalise with `net.ParseIP(...).String()` before fingerprinting; an
+unparseable value is the literal string `"unknown"` and shares one
+bucket. Never logged.
---
@@ -141,228 +166,333 @@ saturday 10:30–13:00; sunday 14-16
```
- **Day part**: a weekday name in any spelling `weekdayAlt` knows,
- optionally plural (`mondays`), optionally `every`; a weekday RANGE with
- the same separators as quick add (`mon-wed`, `monday to friday`);
- `weekdays`; `weekends`; `every day` / `daily`.
-- **Time range**: `reRange` from quick add, verbatim. `12–3` uses the
- working-day rule: a bare 1–7 is afternoon, so `5-7` is 17:00–19:00 and
- `12-3` is 12:00–15:00. `9-5` is 09:00–17:00 because 5 → 17:00. A range
- whose end resolves before its start (`11-3` → 11:00–15:00 is fine;
- `10pm-2` is not) is **rejected with its text quoted**, never wrapped
- past midnight: a booking window that crosses midnight is not a thing a
- host means.
-- **A clause with no day part inherits the previous clause's day.**
- `tuesdays 12–2, 5-7` is two windows on Tuesday. This is the case in
- the request and it is the one an implementer will get wrong.
-- Filler words are dropped: `between`, `from`, `and`, `on`, `at`.
+ optionally plural, optionally `every`; a weekday RANGE with the quick
+ add separators (`mon-wed`, `monday to friday`), walking forward
+ modulo 7; `weekdays` (Mon–Fri); `weekends` (Sat, Sun); `every day` /
+ `daily` / `all days`.
+- **Time range**: two clocks joined by `-`, `–`, `—`, `to`, `until`,
+ `till`. Each clock is `H`, `H:MM`, `Ham`, `H:MMpm`. **Raw components
+ are validated BEFORE any shared helper runs**: hour `0..24`, minute
+ `0..59`, and `24` only as `24:00` at the END of a range. The quick-add
+ helpers clamp and forgive; here nothing is forgiven (finding 17).
+ Meridiem: an explicit `am`/`pm` on either end applies to both unless
+ the other says otherwise; with no meridiem anywhere the working-day
+ rule applies (a bare 1–7 is afternoon; 8–12 as written), so `5-7` is
+ 17:00–19:00 and `9-5` is 09:00–17:00. A range whose end is not after
+ its start is **refused with its text quoted**: a booking window does
+ not cross midnight.
+- **A clause with no day part inherits the previous clause's days.**
+ `tuesdays 12–2, 5-7` is two windows on Tuesday. A first clause with
+ no day part is a problem.
+- Filler dropped: `between`, `from`, `and`, `on`, `at`.
### 3.2 What it refuses
-Anything it cannot read is **quoted back in the pane as not understood**
-and the page cannot be saved while any clause is. A window the parser
-invented from a clause it half-read would put a stranger in the host's
-calendar at a time the host never offered. Refusal is the safe failure
-here, and it is the opposite of quick add's "leave it in the title"
-because there is no title to leave it in.
+Every token not consumed by a day part, a time range or a filler word
+is a problem, quoted. Every problem in the sentence is reported, not
+just the first. The page cannot be saved while any exists.
### 3.3 Where it lives
-`internal/calendar/avail.go` (+ `avail_test.go` in `calendartest`).
-`ParseAvailability(text string) (windows []Window, problems []string)`.
-Pure. It reuses `consumed`, `weekdayAlt`, `rangeSep`, `reRange`,
-`applyMeridiem`, `clock` from the quick-add files — importing them,
-never copying them. If a shared helper needs to move to a common file to
-be shared cleanly, move it in the same commit.
+`internal/calendar/avail.go`, `ParseAvailability(text) (windows
+[]Window, problems []string)`, pure. Shares `consumed`, `weekdayAlt`,
+`weekdays`, `rangeSep` with quick add. Does NOT call `clock` or
+`applyMeridiem` on unvalidated input.
### 3.4 The pane
-`POST /settings/booking/parse` returns a fragment listing the windows
+`POST /settings/booking/parse` returns a fragment listing merged windows
in words — "Monday 12:00–15:00 · Tuesday 12:00–14:00, 17:00–19:00" —
-and any problems, each with the clause quoted. `booking.js` is a
-debounced fetch and nothing else, cloned from `quickadd.js`. Without
-script, saving the form renders the same fragment inline on the
-settings page, with the form re-filled. The pane is the contract: a
-window not in the pane does not exist.
+and every problem. `booking.js` is a debounced fetch cloned from
+`quickadd.js`. Without script, saving renders the same fragment inline
+at 422 with the form re-filled.
---
## 4. Slots
-`FreeSlots(ctx, page, host, from, to time.Time) ([]Slot, error)` in
-`internal/calendar/slots.go`.
-
-1. Clamp `from` to `now + notice_min` and `to` to `now + horizon_days`.
-2. For each calendar day in `[from, to)` **in the host's zone**, using
- `DayBounds` (a day is 23, 24 or 25 hours): take the windows for that
- weekday.
-3. Within each window, candidate starts step by `duration_min` from the
- window's start. A candidate whose end exceeds the window's end is
- dropped, not clamped.
-4. Fetch busy once for the whole range: `EventsInRange(host)` filtered to
- `Busy(kind) && status != cancelled`. **Tentative counts as busy** —
- that is how a hold blocks a slot (B8). All-day events count as busy
- for their whole day.
-5. Drop any candidate whose `[start - buffer, end + buffer)` overlaps a
- busy interval. Half-open everywhere.
-6. Return slots as UTC instants plus the host-zone wall clock they were
- derived from. The public page converts to the booker's zone for
- display only.
-
-**DST.** Windows are wall-clock in the host's zone. A 09:00–12:00 window
-on the spring-forward Sunday is still 09:00–12:00 local, and it is three
-hours long because neither 09:00 nor 12:00 is in the gap. A window that
-straddles the gap (01:00–03:00) is one hour long that day; on the fold
-day it is three. `DayBounds` and `ToUTC` already encode this and the
-tests pin it (§9).
-
-**Why not a 15-minute grid.** Calendly steps by a grid so a 25-minute
-meeting can start at :15. Stepping by the duration is simpler, gives
-fewer near-identical choices, and matches how people write windows. If
-this is wrong it is a one-line change with a test.
+`ComputeSlots(rule, busy, now, from, to)` is pure, in
+`internal/calendar/slots.go`. `FreeSlots` in `store_slots.go` fetches
+busy and calls it.
+
+1. **Bounds.** `earliest = now + notice_min`; `latest =
+ now.In(hostLoc).AddDate(0,0,horizon_days)`. A candidate is offered
+ only if `start >= earliest`, `start >= from`, `end <= to` and `end
+ <= latest`. Each candidate is tested individually; clamping the
+ iteration range is an optimisation, never the filter (finding 16).
+2. **Days.** Iterate host-zone calendar days from `from` to `to` by
+ `AddDate(0,0,1)` on a local midnight, which is the next local
+ midnight whatever the day's length.
+3. **Candidates.** For each window on that weekday, for `m` from
+ `start_min` stepping `duration_min` while `m + duration_min <=
+ end_min`: `start = time.Date(y, mo, d, 0, m, 0, 0, hostLoc)`.
+ **Round-trip rule:** the candidate is dropped unless
+ `FormatLocal(start) → ToUTC` gives back `start.Unix()` AND
+ `start.Hour()*60+start.Minute() == m`. That drops the spring gap
+ (the wall clock does not exist) and, on the fold, keeps exactly the
+ occurrence `ToUTC` would store — the second one, per `clock.go` —
+ so what is offered is what the record can hold (finding 13). `end =
+ start + duration` as ELAPSED time from the instant.
+4. **Busy.** Fetched once over `[from - 1 day, to + 1 day)` so a buffer
+ at the edge sees its neighbour (finding 15). From `EventsInRange(host)`
+ using `OccStartUTC`/`OccEndUTC`, keep `Busy(kind) && status !=
+ cancelled`; **tentative is busy**; the event being rescheduled is
+ excluded by id; **a tentative event whose booking is `held` with
+ `hold_expires_at <= now` is NOT busy** (a join, so an unswept hold
+ frees its slot the moment it lapses — finding 19). **All-day
+ events** are re-derived: their `StartLocal`/`EndLocal` dates through
+ `DayBounds` in the HOST's zone, not their stored UTC index, which is
+ midnight in the event's own (possibly empty) zone (finding 14).
+ All-day NOTES are not busy, like every note.
+5. **Buffer.** Drop a candidate if `[start - buffer, end + buffer)`
+ overlaps any busy interval. Half-open.
+6. Return ascending `Slot{StartUTC, EndUTC, Local}`; the page groups
+ them by the BOOKER-zone date for display (finding 16), and the POST
+ carries the instant, never a date.
+
+Tests pin the spring-forward and fold Sundays for `Europe/Dublin` 2026
+with windows that TOUCH the transition (00:00–04:00), to real unix
+numbers, as `clock_test.go` does.
---
## 5. The public flow
All public routes sit in the chi router beside `/feed/{token}.ics`:
-inside `csrf.Protect`, which is app-wide, and OUTSIDE the signed-in
-group. `csrf.Protect` checks origin on state-changing methods only, so
-GETs pass, and a stranger's browser posting our own form sends
-`Sec-Fetch-Site: same-origin`, so those pass too; a cross-site POST is
-refused, which is correct.
-
-### 5.1 `GET /book/{handle}/{slug}`
-
-The page: title, description, duration, the host's first name if they
-set one (sealed name, opened server-side; never the address). A zone
-`<select>` defaulting to the host's zone. A list of the next days that
-have any free slot, as links to `?day=YYYY-MM-DD&zone=…`. With `day`
-set, that day's slots as buttons — a `<form method=post>` each, carrying
-`start` (UTC unix) and `zone`. No JS required; `booking.js` may
-preselect the zone from `Intl.DateTimeFormat().resolvedOptions().timeZone`
-and nothing else.
-
-Inactive page, unknown handle, unknown slug: 404, same body, same
-timing as far as we control it.
-
-### 5.2 `POST /book/{handle}/{slug}` — hold
-
-Fields: `start`, `zone`, `name`, `address`, `note` (optional, 2000 max).
-
-1. Recompute free slots for that day. If `start` is not one of them:
- re-render with "That time has just gone" and the day's remaining
- slots. Never trust the posted start.
-2. Caps (§7). Over cap: 429 with a plain page.
-3. In one transaction:
- - Create the EVENT on the host's **personal** calendar (ruling 8 in
- AGENTS.md: personal by default). Title `"{page title} — {name}"`,
- sealed. Kind meeting, **status tentative**, start/end from the
- slot, TZ the host's zone. Notes sealed: the booker's note.
- - Create the booking row, state `held`, `hold_expires_at = now +
- 30m`, token minted and hashed.
-4. Mail the booker (§6.1). If the send fails, roll back both rows and
- render "We could not send to that address" — a hold nobody can
- confirm is a slot stolen for thirty minutes for nothing.
-5. Render "Check your mail" with the address echoed back (it is theirs;
- they just typed it) and the expiry time in their zone.
-
-### 5.3 `GET /book/m/{token}` — confirm and manage
-
-Look up by `HashFeedToken(token)`. Not found → 404 (B10).
-
-- `held`, not expired: **confirm it**. This is a GET that mutates, and
- that is deliberate: it is the link in the mail and it must work from
- any mail client, including ones that prefetch. Confirming twice is
- idempotent. Set `confirmed_at`, event status → confirmed, create the
- attendee row (`SetAttendees`), mail the host (§6.2), then render the
- manage page.
-- `held`, expired: render "This hold expired at …; book again" with a
- link back to the page. The sweep will tidy the row.
-- `confirmed`: render the manage page: what, when (in `booker_zone`),
- where (nothing; there is no location), an **Add to calendar** link
- to `.ics`, a **Reschedule** link, a **Cancel** form.
-- `cancelled` / `expired`: say so, link back to the page.
-
-### 5.4 `GET /book/m/{token}.ics`
-
-`text/calendar`, `Content-Disposition: attachment; filename=…`. One
-VEVENT: `METHOD:REQUEST` while confirmed, `METHOD:CANCEL` and
-`STATUS:CANCELLED` once cancelled. `ORGANIZER;CN=host:mailto:host` —
-the host's address IS in the ICS, because the booker needs to know who
-they are meeting and their client needs it to file replies; this is the
-same information the confirmation mail already carries in its From.
-`ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:booker`. `SEQUENCE` from
-the row. `URL:` the manage link. `UID` stable across reschedules. Built
-with `WriteICS`-adjacent helpers, never a second folding implementation.
-
-### 5.5 `GET/POST /book/m/{token}/reschedule`
-
-GET: the slot picker from §5.1 for the same page, with the current
-booking's own slot **excluded from busy** (otherwise the booker can
-never pick the slot next to their own). POST `start`: validate as in
-§5.2 step 1, update the event's start/end (explicit column allowlist,
-never a model bind), bump `sequence`, mail both parties (§6.3), render
-the manage page.
-
-### 5.6 `POST /book/m/{token}/cancel`
-
-Event status → cancelled, `cancelled_at`, `sequence++`, mail both
-(§6.4). The event stays on the host's calendar labelled Cancelled,
-exactly as any other cancelled event does — colour never carries meaning
-alone.
+inside `csrf.Protect`, outside the signed-in group. GETs pass the origin
+check; POSTs from our own page carry `Sec-Fetch-Site: same-origin`.
+CSRF is not the abuse control — a script can send that header; §7 is.
+
+Every public response sets `Cache-Control: no-store` and
+`Referrer-Policy: no-referrer` (the token is in the path). The request
+path with the token WILL appear in rastrillo's access log; that is the
+same residual the feed URL has and is stated here rather than hidden.
+
+### 5.1 `GET /book/{handle}/{code}`
+
+`PublicBookingPage(handle, code)` → 404 for an unknown handle, unknown
+code or unpublished page, byte-identical to the router's own 404. The
+page: title, description, duration, the host's first name (opened
+`NameSealed`) or handle, and a zone `<select>` whose options are
+**exactly `CommonZones`** — `?zone=` outside that set is ignored and
+the host's zone used. This bounds the input to `Zone()` (finding 5; the
+cache is also made concurrency-safe, §9).
+
+No `?day=`: the next days with any free slot, as links carrying `day`
+(a BOOKER-zone date) and `zone`. With `day`: that day's slots in the
+booker's zone, each a `<form method=post>` with hidden `start` (UTC
+unix) and `zone` and a button. Pressing one posts to the same URL
+**without `name`**, and the handler renders the page again with that
+slot fixed at the top and the details form — `name`, `address`, `note`
+— beneath it, still carrying `start` and `zone` hidden. Two POSTs, no
+script. `booking.js` may only preselect the zone.
+
+### 5.2 `POST /book/{handle}/{code}` with `name` — hold
+
+Limits: `name` 1–100 runes; `address` must match `reAddress` in full
+and normalise to one address; `note` ≤ 2000 runes; body ≤ 16 KiB
+(`http.MaxBytesReader`). Anything over → 422 with the form.
+
+Then, **inside `holdMu` (B12)**:
+1. §7 caps. Over → 429.
+2. `FreeSlots` for the slot's own day; `start` must be one of them
+ exactly, or 409 "That time has just gone" with the day's remaining
+ slots.
+3. One transaction: the event on the host's personal calendar — title
+ `"{page title} — {name}"`, notes the booker's note, kind meeting,
+ **status tentative**, TZ the host's zone, `start_local`/`end_local`
+ from the instant in that zone; and the booking row, `held`,
+ `hold_expires_at = now + 30m`, `pending_mail = 'hold'`, token minted
+ and hashed.
+
+Then, outside the mutex: send the hold mail (§6). Success → clear
+`pending_mail`. **Definite** failure (the sender returned an error) →
+state `failed`, event soft-deleted, 422 "We could not send to that
+address"; the row stays and counts toward §7. Otherwise the response is
+200 "Check your mail" with the address echoed and the expiry in the
+booker's zone.
+
+### 5.3 `GET /book/m/{token}`
+
+`BookingByToken` → 404. Then by state:
+
+- `held`, `now < hold_expires_at`: a page saying what is being booked
+ and when (booker zone), with ONE form: `POST /book/m/{token}/confirm`,
+ button "Confirm this booking". Below it: "Didn't ask for this? Do
+ nothing; it expires at …". **Nothing changes on this GET.** (B1.)
+- `held`, expired, or `expired`: "This hold expired at …", link to the
+ page.
+- `confirmed`: the manage page (§5.5).
+- `cancelled`, `failed`: says so, link to the page.
+
+### 5.4 `POST /book/m/{token}/confirm`
+
+Transition `held → confirmed` per §5.7, conditional on `state = 'held'
+AND hold_expires_at > now`, all in one transaction: booking state and
+`confirmed_at`; event `status = confirmed`, `sequence + 1`; attendee
+row (`SetAttendees`); the host's default reminder (`EnsureDefaultReminder`
+— finding 18); `pending_mail = 'confirmed'`. Zero rows affected → the
+state has moved; re-render §5.3 for whatever it now is. Then send the
+host mail (§6) and clear the marker. Pressing twice cannot mail twice:
+the second press affects zero rows.
+
+### 5.5 The manage page (state `confirmed`)
+
+What (page title), when (booker zone, and host zone when they differ),
+who (host name/handle and host address), and:
+- **Add to calendar** → `GET /book/m/{token}/calendar.ics`
+- **Reschedule** → `GET /book/m/{token}/reschedule`
+- **Cancel** → a form, `POST /book/m/{token}/cancel`
+
+**The public allowlist (finding 7):** every public HTML page, every
+mail to the booker and the ICS may show ONLY: page title, page
+description, the slot's times, the host's name/handle and address, the
+booker's own name and address, and the booking's state. **Never** the
+event's notes, location, or attendee list, all of which the host may
+have edited since. A test edits the event as the host after
+confirmation and asserts the additions never reach the booker.
+
+**Host authority (finding 11):** the manage page reads the EVENT. If
+the host has set the event cancelled or deleted it, the manage page
+shows cancelled and offers no reschedule; the ICS says `METHOD:CANCEL`.
+`UpdateEvent` refuses an `RRule` on an event that a booking references
+("This is a booked meeting; it cannot repeat."), and `DeleteEvent` on
+one sets the booking `cancelled` in the same transaction with
+`pending_mail = 'cancelled'` so the booker IS told when the host
+deletes — the cheapest honest version of "the host is not a silent
+authority".
+
+### 5.6 Reschedule and cancel
+
+`GET …/reschedule`: the picker from §5.1 with the booking's own event
+excluded from busy, posting to `POST …/reschedule` with `start`, `zone`.
+Inside `holdMu`: `reschedules < MaxReschedules` (§7), `FreeSlots` check,
+then one transaction: event `start_local`/`end_local`/UTC index through
+the same explicit column allowlist `UpdateEvent` uses, `sequence + 1`;
+`RescheduleReminder` for every reminder on the event; booking
+`reschedules + 1`, `pending_mail = 'rescheduled'`. Then mail both
+parties.
+
+`POST …/cancel`: conditional on `state = 'confirmed'`: event `status =
+cancelled`, `sequence + 1`; reminder queue rows for the event deleted;
+booking `cancelled`, `cancelled_at`, `pending_mail = 'cancelled'`. Mail
+both. A second press affects zero rows and mails nobody.
+
+### 5.7 Transitions
+
+| From | Event | To | Guard | Side effects (same transaction) |
+|---|---|---|---|---|
+| — | hold | `held` | caps; slot free; inside `holdMu` | tentative event; `pending_mail='hold'` |
+| `held` | send failed | `failed` | definite sender error | event soft-deleted |
+| `held` | confirm POST | `confirmed` | `hold_expires_at > now` | event confirmed, seq+1; attendee; default reminder; `pending_mail='confirmed'` |
+| `held` | sweep | `expired` | `hold_expires_at <= now` | event soft-deleted |
+| `confirmed` | reschedule | `confirmed` | slot free; `reschedules < max`; inside `holdMu` | event moved, seq+1; reminders rescheduled; `pending_mail='rescheduled'` |
+| `confirmed` | cancel (booker) | `cancelled` | — | event cancelled, seq+1; queue rows deleted; `pending_mail='cancelled'` |
+| `confirmed` | host deletes event | `cancelled` | — | `pending_mail='cancelled'` |
+| any | sweep, 24h after created | same | — | `ip_fp = NULL` |
+
+Every transition is a conditional `UPDATE … WHERE id = ? AND state = ?`
+(plus the guard) and checks `RowsAffected == 1`. Zero rows means
+somebody else moved it first; the caller re-reads and renders, never
+retries the transition. No transition sends mail inside its
+transaction; the marker does.
---
## 6. Mail
-All plain text through `mail.Sender`. Every body is a template in
-`templates/mail/`, so the wording is reviewable as prose. No booker
-content in any log line.
+Plain text through `mail.Sender`, which has one configured sender and
+no per-message From: the host's address is in the BODY (and, for the
+booker, in the ICS as ORGANIZER), never claimed as the envelope sender
+(finding 21). Bodies are `text/template` files in `templates/mail/`.
+No booker content in any log line.
-| | To | Says |
+| `pending_mail` | To | Says |
|---|---|---|
-| 6.1 hold | booker | "You asked to book {title} with {host} on {when in booker zone}. Confirm it here: {link}. The hold expires at {expiry}." |
-| 6.2 confirmed | host | "{name} ({address}) booked {title}, {when in host zone}. Note: {note}. It is on your calendar: {event link}." |
-| 6.3 rescheduled | booker AND host | The new time, both zones where they differ, the manage link (booker) or event link (host). |
-| 6.4 cancelled | booker AND host | Who cancelled (always the booker, in this version), what, when it was. |
-
-The host's mail includes the booker's address and note because the host
-is on the team and that is delivery, not storage — the same argument
-AGENTS.md makes for reminder mail. The booker's mail never includes
-another person's address except the host's, which is the ORGANIZER.
+| `hold` | booker | "Somebody — we hope you — asked to book {title} with {host} on {when, booker zone}. If that was you, confirm it here: {link}. If not, do nothing; it expires at {expiry}." |
+| `confirmed` | host | "{name} ({address}) booked {title}, {when, host zone}. Note: {note}. It is on your calendar: {event link}." |
+| `rescheduled` | booker AND host | The new time in each recipient's zone; the manage link (booker) or event link (host). "If you added this to a calendar, download it again — the file does not update itself." |
+| `cancelled` | booker AND host | What was cancelled, when it had been, who cancelled (booker or host). |
+
+**Delivery (B13):** after the transition commits, the handler sends
+once. Success clears `pending_mail`. Failure leaves it and bumps
+`mail_attempts`; the drain (§8) retries any row with a marker,
+regenerating the body from the sealed columns, up to
+`MaxReminderAttempts`, then clears it and logs `stage=booking.mail
+code=gave_up`. The hold mail is the one exception: a definite failure
+there moves the row to `failed` (§5.2), because a hold nobody can
+confirm is a stolen slot. An ambiguous failure — the sender returned
+success but the mail never arrived — is not detectable and the hold
+simply expires.
+
+The `mail.Sender` ignores its context. Wrap every send in
+`context.WithTimeout(ctx, 20*time.Second)` anyway so a future sender
+that honours it is bounded, and say in a comment that today's does not.
---
## 7. Abuse
-A public POST that sends mail and writes rows needs limits, and the
-limits must not need a new dependency.
-
-- **Per-IP**: at most 5 open holds (`state = held`, not expired) per
- `created_ip_hash`. The IP comes from the platform's forwarding header,
- which rastrillo already resolves for the CSRF origin check; use the
- same accessor.
-- **Per-page**: at most 20 open holds. A popular host with 20 strangers
- mid-confirmation is unlikely; a script holding every slot on a page is
- not.
-- **Per-address**: at most 2 open holds per `booker_address_fp`.
-- Over any cap: 429, "Too many bookings in progress. Try again in half
- an hour." No cap is per-day: expired holds free their count.
-- A stranger can learn the host's free/busy by walking the page. The
- estate already gives that up (§1 of AGENTS.md); the page never says
- WHAT is busy.
+None of these needs a dependency. All are counted from `bookings` rows,
+which persist through every state including `failed` — that is why
+failures are rows, not rollbacks (finding 3).
+
+### 7.1 Caps, checked before any slot arithmetic
+
+| Cap | Key | Window | Limit |
+|---|---|---|---|
+| Open holds | `ip_fp` | `state='held' AND hold_expires_at > now` | 5 |
+| Open holds | `page_id` | same | 20 |
+| Open holds | `booker_address_fp` | same | 2 |
+| Attempts | `ip_fp` | `created_at > now - 24h`, any state | 20 |
+| Attempts | `booker_address_fp` | `created_at > now - 24h`, any state | 6 |
+| Reschedules | booking | lifetime | `MaxReschedules = 5` |
+
+Over any cap: 429, "Too many bookings in progress. Try again later."
+The `ip_fp` caps stop applying to a row when the sweep nulls its
+`ip_fp` at 24 hours, which is exactly when the attempt window closes.
+
+### 7.2 What this does not stop
+
+A patient adversary with many mailboxes and many addresses can still
+book real slots one at a time, confirm them, and waste the host's
+time. So can a patient human with a phone. The host cancels them like
+any other event and, if it continues, unpublishes the page. Nothing
+here claims to stop a determined person; it stops a script from
+emptying a calendar or sending mail in a loop.
+
+### 7.3 The oracle, stated as new
+
+A published page lets **anyone on the internet** ask when this host is
+free, without holding the database. The existing leak list is about
+what a published FILE gives up; this is a live query, and because every
+granted calendar counts it also reflects the team calendar's shape
+(finding 8). So publishing is a deliberate act: the toggle is labelled
+**Publish**, and the sentence above it reads "Anyone with this link can
+see when you are free — not what you are doing, but when — including
+time blocked by team calendars you can see." AGENTS.md's leak list
+gains this as item 5, worded as a new disclosure, not an existing one.
---
## 8. The sweep
-`hold_expires_at` joins `due_at` in `NextDueAt`: the next due instant is
-the earlier of the next reminder and the next hold expiry. `drain` grows
-a step: every `held` booking past expiry → state `expired`, its event
-**deleted** (not cancelled: a hold that was never confirmed was never a
-meeting, and a calendar full of cancelled ghosts from bots is a worse
-calendar). Inside the request, like the reminders; idempotent; at least
-once.
+`drain` (tick and `DrainOnRequest`) runs three bounded steps, each
+independent of whether the others found work (finding 19):
+
+1. **Expire holds**: `held AND hold_expires_at <= now` → `expired`,
+ event soft-deleted, reminder queue rows for it deleted. Bounded to
+ 200 per pass.
+2. **Owed mail**: up to 20 rows with `pending_mail <> ''`, oldest
+ first; send, clear or bump `mail_attempts`.
+3. **Forget IPs**: `ip_fp IS NOT NULL AND created_at <= now - 24h` →
+ `NULL`. Bounded to 500.
+4. Then the reminder loop as today.
+
+`NextDueAt` returns the EARLIEST of: the next reminder `due_at`; the
+next `hold_expires_at` among `held` rows; `now` if any `pending_mail`
+is set. It is what the platform wakes the instance for, so a pending
+mail wakes it now and a hold wakes it at expiry.
---
@@ -370,82 +500,131 @@ once.
Enforced, not remembered.
-- **Parser table** (`avail_test.go`): every example in §3.1, the
- inheritance case, the midnight refusal, plural and singular, `every`,
- `weekdays`, `weekends`, each separator, the en dash, a clause with
- three ranges, two problems in one sentence both reported.
-- **Slots** (`slots_test.go`): a Monday with a 12–3 window and a 60-min
- page gives 12:00, 13:00, 14:00. A busy 13:00–13:30 with no buffer
- removes 13:00 only. With a 10-min buffer, every slot is padded to
- `[start-10m, end+10m)` before the overlap test: 12:00 becomes
- `[11:50, 13:10)` and now overlaps the busy block, so 12:00 AND 13:00
- go and only 14:00 (`[13:50, 15:10)`) survives. A tentative event
- blocks. A cancelled one does not. A note-kind event does not. An
- all-day event blocks the day. `notice_min` hides the next two hours.
- `horizon_days` ends the list. The spring-forward Sunday
- (`Europe/Dublin`, 29 March 2026) and the fold Sunday (25 October
- 2026) pinned to real unix numbers, as `clock_test.go` does.
+- **Parser** (`avail_test.go`): every example in §3.1; inheritance; a
+ first clause with no day; midnight refusal; `25:00`, `9:60`, `24:00`
+ at start vs end; plural/singular; `every`; `weekdays`/`weekends`;
+ each separator; the en dash; three ranges; two problems both
+ reported; overlapping windows merged.
+- **Slots** (`slots_test.go`): the 12–3 / 60-min arithmetic with and
+ without a 10-min buffer (12:00 and 13:00 go, 14:00 stays); tentative
+ blocks; a lapsed-but-unswept hold does not; cancelled does not; a note
+ does not; an all-day commitment blocks the HOST's whole day even when
+ the host is in `Asia/Tokyo` and the event's zone is empty; notice
+ hides the near future per-candidate; horizon ends the list; a busy
+ block ending five minutes before `from` still removes the first slot
+ when buffered; the spring-forward and fold Sundays with a 00:00–04:00
+ window pinned to real unix numbers; a Tokyo Monday slot displays on a
+ Los Angeles Sunday and books the right instant.
+- **Concurrency**: 20 goroutines hold the same slot at once; exactly one
+ succeeds, the rest get `ErrSlotTaken`. Run with `-race`.
- **Flow** (`booking_http_test.go`): through the router with
- `pickyMailer`. Hold creates a tentative event and mails one link;
- the link confirms, creates the attendee, mails the host; confirming
- twice is idempotent; a second hold on the same slot is refused with
- "just gone"; reschedule excludes own slot from busy and bumps
- `SEQUENCE`; cancel produces `METHOD:CANCEL`; a wrong token 404s and
- the body is byte-identical to an unknown route's 404; an expired
- token says expired; the ICS round-trips through `ParseICS`.
-- **Caps**: the sixth hold from one IP 429s; expiring one frees it.
-- **Sweep**: `tick` after expiry deletes the event and the slot is free
- again; `NextDueAt` returns the hold's expiry when it is sooner than
- any reminder.
+ `pickyMailer`. Hold → one mail; the link's GET changes nothing (state
+ still `held`, no host mail); the confirm POST confirms, records the
+ attendee, creates the default reminder, mails the host once; a second
+ POST affects nothing and mails nobody; the same slot cannot be held
+ twice (409); wrong token 404 byte-identical to a route 404; expired
+ says expired; the ICS has `METHOD:REQUEST`, `ORGANIZER`, `ATTENDEE
+ …ACCEPTED`, `SEQUENCE:` equal to the event's, NO `URL:`, and parses
+ with the app's own importer; reschedule bumps the EVENT sequence and
+ mails both; cancel gives `METHOD:CANCEL`; **the host adds a note,
+ a location and a second attendee after confirmation, and none of the
+ three appears in the manage page, the ICS or any booker mail**; the
+ host deletes the event and the booker's page shows cancelled and the
+ booker is mailed; `UpdateEvent` refuses an RRule on a booked event.
+- **Mail outbox**: a refused host mail leaves `pending_mail='confirmed'`;
+ `tick` retries it; `NextDue` is `<= now` while it is owed.
+- **Caps**: the sixth open hold from one IP 429s; expiry frees it; the
+ 21st attempt in 24h 429s even though every earlier hold failed;
+ `MaxReschedules` is enforced.
+- **Sweep**: `tick` expires a hold, deletes its event and reminder
+ rows, frees the slot; nulls `ip_fp` after 24h; `NextDueAt` returns
+ the hold's expiry when it is sooner than any reminder.
+- **`Zone()`**: 100 goroutines resolve 100 distinct invalid names
+ concurrently under `-race`; the cache holds no failures afterwards.
- **The one rule**: `TestTheDatabaseNeverHoldsAPlaintextTitle` gains a
- booking fixture — booker name, address, note, page title,
- availability text — and greps the raw bytes for each. The token is
- asserted absent too.
-- **Logs**: the picky mailer's failure path is exercised and the log
- buffer is grepped for the booker's address.
-- **Browser** (`make browser`, sixth and seventh passes): a stranger
- with JavaScript OFF books a slot end to end, reads "check your mail",
- and the recorded mail's link confirms. With JS on, the settings pane
- fills in from the availability text.
+ booking fixture — booker name, address, note, page title, description,
+ availability text, token — grepped from the raw bytes.
+- **Logs**: a `slog` handler over a buffer; a failed send; the buffer
+ has no address and no token.
+- **Browser** (`make browser`): a stranger with JavaScript OFF books a
+ slot end to end, reads "check your mail", opens the recorded link,
+ presses Confirm, sees Reschedule. With JS on, the settings pane fills
+ in from the availability text.
---
## 10. What is NOT in this version, said so nobody reads it in
-- **Group / round-robin pages.** One host per page.
-- **Booker questions / custom fields.** Name, address, one note.
-- **Host-initiated reschedule or cancel from the manage page.** The host
- edits or cancels the event like any other; the booker is NOT mailed
- when they do. This is a real gap and the event page says so beside
- the booking badge: "Changing this here does not tell {name}."
-- **Payments, video links, reminders to the booker.** The host's own
- reminders apply to the event as to any event.
+- **Group / round-robin pages.**
+- **Booker questions / custom fields.**
+- **Host-initiated reschedule from the manage flow.** The host edits
+ the event like any other; the booker is told only on DELETE (§5.5).
+ An edit that moves the time does not mail the booker, and the event
+ page says so beside the booking badge: "Moving this here does not tell
+ {name}. Cancel and let them rebook, or tell them yourself."
+- **Automatic calendar sync.** The ICS is a download of the current
+ state. A recipient who imported it re-imports after a change mail; the
+ mail says so.
+- **Payments, video links, reminders to the booker.**
- **Locale.** English strings, like the rest of the app.
-- **Cancelling a confirmed booking purges nothing.** The row and the
- cancelled event stay until a later retention decision.
+- **Retention.** Cancelled and expired rows stay, sealed, minus `ip_fp`.
---
## 11. Changes to AGENTS.md in the same commit
-- Settled decisions: add B1, B2, B4, B5, B8 as rows.
-- Mechanical rules → Frontend: after "Attendees are recorded, never
- invited", add: **"Except a BOOKER, who typed their address to be
- mailed. Booking mail is the one outbound to a non-team address."**
-- Mechanical rules → Data: **"Booker name, address and note are sealed;
- the address has the keyed fingerprint. Booking windows are plaintext:
- they are shape-of-time."**
-- Mechanical rules → Reminders → rename the section **Ticks**, and add:
- **"`NextDue` is the earlier of the next reminder and the next hold
- expiry. Add a third kind of scheduled work here, not in a goroutine."**
-- The one rule's "what this does NOT cover": add **"5. A booking page is
- a public free/busy oracle for that host. Stepping through it learns
- the host's free time, which the estate already gives up."**
+- Settled decisions: rows for B1, B2, B4, B5, B8, B11, B12, B13.
+- Frontend: after "Attendees are recorded, never invited": **"Except a
+ BOOKER, who typed their address to be mailed. Booking mail is the one
+ outbound to a non-team address."**
+- Data: **"Booker name, address and note are sealed; the address has
+ the keyed fingerprint. A page's public code is generated, never a
+ chosen slug — a slug is prose. Booking windows are plaintext: they are
+ shape-of-time."**
+- Data: **"Every booking transition is a conditional UPDATE on state
+ that checks RowsAffected. Zero rows is 'someone moved it first', never
+ a retry."**
+- Reminders → renamed **Ticks**: **"`NextDue` is the earliest of the
+ next reminder, the next hold expiry, and now-if-mail-is-owed. A new
+ kind of scheduled work goes here, never in a goroutine."**
+- The one rule's exclusions, new item: **"5. A PUBLISHED booking page
+ is a live free/busy oracle for that host, to anyone, without the
+ database. This is a new disclosure and the publish toggle says so."**
+- Mechanical rules → Time: **"`Zone()` takes bounded input on public
+ routes: `CommonZones` only."**
---
## 12. Review
-Reviewed by `codex-cli 0.153.0` on 2026-09-07. Findings and
-dispositions are recorded below after the review runs; this section is
-filled in the same commit that acts on them.
+**Round 1 — GPT-6 via `codex-cli 0.153.0`, 2026-09-07. Verdict:
+REWRITE.** 21 findings. Dispositions:
+
+| # | Finding | Disposition |
+|---|---|---|
+| 1 | Plaintext slugs are prose joined to events | **Accepted.** Generated 8-char codes (B4, §2.2). |
+| 2 | GET-confirms is confirmed by mail-gateway prefetch | **Accepted, and it was the worst thing in v1.** GET renders a button; POST confirms (B1, §5.3–5.4). |
+| 3 | Open-hold caps do not bound mail abuse | **Accepted.** Attempt caps over 24h counting every state incl. `failed`; `MaxReschedules` (§7). |
+| 4 | Named IP accessor does not exist; permanent linkage | **Accepted.** Source is the edge's `SetXForwarded`, last XFF element, verified in platform source (§2.5); `ip_fp` nulled at 24h. |
+| 5 | `Zone()` cache is an unsynchronised map that caches failures, fed by public input | **Accepted, and it is a pre-existing bug.** Public zone bounded to `CommonZones`; cache made safe and failures uncached; concurrent test (§5.1, §9). |
+| 6 | ICS carries the management capability | **Accepted.** No `URL:`; `no-store`; `no-referrer` (B6, §5). |
+| 7 | Host's later private edits leak to the booker | **Accepted.** Explicit public allowlist and a test that edits then checks (§5.5). |
+| 8 | The oracle is a NEW disclosure | **Accepted.** Reworded; explicit Publish with the sentence (§7.3, §11). |
+| 9 | Two strangers hold one slot | **Accepted.** Process mutex + in-mutex check + 20-goroutine race test (B12). |
+| 10 | Transitions not atomic | **Accepted.** Transition table, conditional updates, RowsAffected (§5.7). |
+| 11 | Two sequence counters; host edits undefined | **Accepted.** Event sequence only; host cancel/delete flows through; RRule refused on booked events (B11, §5.5). |
+| 12 | Mail and rows cannot roll back together | **Accepted.** Commit then send; `pending_mail` outbox retried by the drain; `failed` state for a definite hold-mail failure (B13, §6). |
+| 13 | Fold example wrong; duration semantics undefined | **Accepted.** Round-trip rule; elapsed duration; window-touching-transition tests (§4.3). |
+| 14 | All-day busy from UTC index is wrong in other zones | **Accepted.** Re-derived through `DayBounds` in the host zone (§4.4). |
+| 15 | Buffer needs busy outside the range | **Accepted.** Envelope ±1 day, Occ fields (§4.4). |
+| 16 | Clamping is not filtering; `day` zone ambiguous | **Accepted.** Per-candidate inequalities; `day` is booker-zone; POST carries the instant (§4.1, §4.6). |
+| 17 | Quick-add helpers forgive malformed clocks; overlapping windows | **Accepted.** Raw validation first; windows merged (§2.3, §3.1). |
+| 18 | Reminders not wired into transitions | **Accepted.** Default reminder at confirm; reschedule/cancel/expire touch the queue (§5.4–5.7). |
+| 19 | Sweep after an early-exit loop never runs; unswept holds still block | **Accepted.** Three independent steps before the loop; lapsed holds excluded from busy by join (§4.4, §8). |
+| 20 | Forms underspecified; settings ownership; terms snapshot | **Accepted.** Two-step scriptless form with limits (§5.1–5.2); 404 outside ownership (implementation plan); a booking's terms live on its event, page edits do not touch existing events. |
+| 21 | ICS "updates in place" overclaims; From is fixed; METHOD placement | **Accepted.** Download semantics stated; host address in body/ORGANIZER; METHOD on VCALENDAR (B6, §6, §10). |
+
+Rejected: none. Cut from v1 as a result: chosen slugs, the booking
+sequence, the `URL:` line.
+
+**Round 2** — to be recorded below after the revised spec is reviewed.