oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

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, a mailed link whose button confirms, a tokenised ICS download, and booker-side reschedule and cancel.

Architecture: Booking pages and their parsed windows are new tables owned by a person. A hold is a TENTATIVE event on the host's personal calendar plus a bookings row keyed by a hashed (and sealed) token; the existing range query makes a hold block a slot. Hold creation and rescheduling serialise on one process-wide mutex. Every state change is a conditional UPDATE naming the state and revision it expects and checking RowsAffected. Mail is a queue table with a claim lease — the reminder queue's own pattern — retried by the existing drain. Public routes live beside /feed/{token}.ics: inside CSRF, outside the session group.

Tech Stack: Go, rastrillo v0.23.0 (chi, GORM + modernc SQLite, mail.Sender, csrf.Protect, carlos.Tick), html/template + text/template, Playwright for the browser gate.

Spec: docs/superpowers/specs/2026-09-07-booking-design.md revision 6. Read it first, in full, including §12. Tasks 1–3 below were written against revision 3 and Task 1 has landed; the section "Revision 4–6 addenda" at the end of this plan amends Tasks 3–8 and is BINDING over the task text where they differ. Every task cites the sections it implements. Where this plan and the spec disagree, the spec wins and this plan is wrong — fix the plan in the same commit.

Global Constraints

Copied from AGENTS.md and the spec. Every task inherits them.

  • The one rule. Booker name, address, note, the token, page title, description and availability text are SEALED (s.seal.SealString). The booker address ALSO gets s.seal.Fingerprint(seal.PurposeAttendeeLookup, addr). Windows, booker_zone, code, ip_fp, states, revisions and instants are plaintext.
  • No title, place, note, address or token in any log line, error string or metric label. scrubErr(err) on every logged error, with a "stage" key.
  • A row that isn't yours 404s, never 403s. Unknown handle, code or token → the same body the router gives an unknown route.
  • Never bind a request body onto a model. Explicit Input structs; explicit column allowlists in every Updates.
  • Every booking transition is a conditional UPDATE (WHERE id = ? AND state = ? plus its guard) checking RowsAffected == 1. Zero rows → re-read and render, never retry. Sequence bumps are gorm.Expr("sequence + 1"), never existing.Sequence + 1.
  • Hold and reschedule run inside Store.holdMu (sync.Mutex on Store), with caps and the slot check inside the lock.
  • No sender error is definite. Nothing is freed, deleted or marked failed because Send returned an error. Attempts go up; the lease expires; the drain retries.
  • Never sessions.UserID. CurrentPerson(r).
  • Migrations are generated. rm internal/calendar/migrations/schema.sql; add models to Models; go run github.com/carlosframework/rastrillo/cmd/rastrillo migration generate; then ... migration check.
  • 300-line cap on every .go and .js. Split by responsibility.
  • *`rst- attribute grammar**, never class spellings, except the seven class-only utilities. This app's components take cal-. Rastrillo partials: field-text, field-textarea, field-select, field-check, form-foot, callout, page-header, detail-list, empty-state, badge — settings.html shows the dict convention; ~/go/pkg/mod/github.com/carlosframework/rastrillo@v0.23.0/ui/partials/*.html` has the keys.
  • No affordance may require script. booking.js is a debounced fetch plus a zone preselect.
  • Every range is half-open. DayBounds, never Add(24h).
  • Public zone input is CommonZones ∪ {host zone}.
  • Work inside the request. The one goroutine allowed is timedSender's, and the request WAITS on it (§6).
  • make ci before every commit (sandbox off if the go cache is read-only). Trailer: Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>.
  • Tests in internal/calendartest unless they need unexported names. Helpers: signedIn, getAs, postAs, follow, newAppWithMailer(t, *pickyMailer), tick(t, h), app.LastMailForTest(), app.GrepDatabaseForTest, app.DBForTest(), app.SignInForTest, calendar.RequestForTest(session, method, target, form). pickyMailer.Send refuses when subject+body contains refuse; Task 6 extends it to also match to and records to per delivery.

---

File map

| File | Responsibility | Task | |---|---|---| | internal/calendar/models_booking.go | BookingPage, BookingWindow, Booking, BookingMail, Window, constants | 1 | | internal/calendar/models.go, seal/seal.go, testing.go | Person.Handle; register; purposes; test hooks | 1 | | internal/calendar/migrations/0005_*.sql, schema.sql | generated | 1 | | internal/calendar/store_booking_page.go | handle, page CRUD, publish, public lookup, ownership | 1 | | internal/calendar/clock.go, zones.go | Zone() safe; IsCommonZone | 2 | | internal/calendar/avail.go, avail_describe.go, templates/_bookingpane.html | parser; merge/describe; pane | 2 | | internal/calendar/store_range.go | override-of-finished-series fix | 3 | | internal/calendar/slots.go, store_slots.go | ComputeSlots; BusyFor; FreeSlots | 3 | | internal/calendar/store_booking_flow.go | hold, confirm, reschedule, cancel | 4 | | internal/calendar/store_booking_read.go | BookingByToken, BookingView, counts | 4 | | internal/calendar/store_booking_mail.go | queue, claim, ack, NextBookingMailDue | 4 | | internal/calendar/store_booking_sweep.go | expire, forget IPs, NextHoldExpiry, NextIPForget | 4 | | internal/calendar/store_reminder_next.go, store_drain.go, store_event.go, store.go | NextDueAt four sources; claim path checks status; booked-event rules; holdMu | 4 | | internal/calendar/handlers_booking_settings.go, handlers_booking_form.go, templates, booking.js | host UI | 5 | | internal/calendar/handlers_booking_public.go, handlers_booking_manage.go, handlers_booking_util.go | public flow; IP; bucket; 404 | 6 | | internal/calendar/booking_ics.go, booking_mail.go, mail_timed.go, templates/mail/*.txt | ICS; mail rendering; timedSender | 6 | | internal/calendar/templates/book_*.html | public UI | 6 | | internal/calendar/handlers_jobs.go | drain ordering; DrainOnRequest one thing | 7 | | internal/calendar/routes.go, render.go, templates/layout.html, static/calendar.css | wiring | 5, 6 | | tests: booking_store_test.go, zone_test.go, avail_test.go, slots_test.go, occurrence_test.go (+1), booking_flow_test.go, booking_mail_test.go, booking_http_test.go, booking_abuse_test.go, booking_sweep_test.go, events_test.go (+fixture) | | 1–7 | | test/browser/booking.mjs, playwright_test.go, Makefile, package.json | browser | 7 | | AGENTS.md | §11 | 8 |

---

Task 1: Models, migration, purposes, page store, ownership

Spec §2.1–2.6, §7.4 (publish).

Produces:

// models_booking.go
const (
	BookingHeld      = "held"
	BookingConfirmed = "confirmed"
	BookingCancelled = "cancelled"
	BookingExpired   = "expired"

	CancelledByNobody = ""
	CancelledByBooker = "booker"
	CancelledByHost   = "host"

	MailHold        = "hold"
	MailConfirmed   = "confirmed"
	MailRescheduled = "rescheduled"
	MailCancelled   = "cancelled"
	ToBooker        = "booker"
	ToHost          = "host"

	HoldTTL        = 30 * time.Minute
	MinNotice      = 10 * time.Minute
	MaxReschedules = 5
	IPForgetAfter  = 24 * time.Hour
	MailGaveUp     = int64(-1) // sent_at value meaning "stopped trying"

	MaxPageTitleBytes  = 100
	MaxPageDescBytes   = 1000
	MaxBookerNameRunes = 60
	MaxBookerNameBytes = 80
	MaxNoteRunes       = 1000
	MaxNoteBytes       = 4000
)

type Window struct{ Weekday time.Weekday; StartMin, EndMin int }

type BookingPage struct {
	ID                 int64     `gorm:"primaryKey"`
	PersonID           int64     `gorm:"column:person_id;not null;uniqueIndex:idx_booking_page_code"`
	Code               string    `gorm:"column:code;not null;uniqueIndex:idx_booking_page_code"`
	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"`
	PublishedAt        *int64    `gorm:"column:published_at"`
	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"`
	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;not null;index"`
	TokenHash           string    `gorm:"column:token_hash;not null;uniqueIndex"`
	TokenSealed         []byte    `gorm:"column:token_sealed"`
	State               string    `gorm:"column:state;not null;index"`
	BookerNameSealed    []byte    `gorm:"column:booker_name_sealed"`
	BookerAddressSealed []byte    `gorm:"column:booker_address_sealed"`
	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"`
	CancelledBy         string    `gorm:"column:cancelled_by;not null"`
	IPFP                *string   `gorm:"column:ip_fp;index"`
	IPForgetAt          int64     `gorm:"column:ip_forget_at;not null;index"`
	Reschedules         int       `gorm:"column:reschedules;not null"`
	CreatedAt           time.Time `gorm:"column:created_at;not null"`
	UpdatedAt           time.Time `gorm:"column:updated_at;not null"`
}

type BookingMail struct {
	ID        int64     `gorm:"primaryKey"`
	BookingID int64     `gorm:"column:booking_id;not null;uniqueIndex:idx_booking_mail_once"`
	Kind      string    `gorm:"column:kind;not null;uniqueIndex:idx_booking_mail_once"`
	Recipient string    `gorm:"column:recipient;not null;uniqueIndex:idx_booking_mail_once"`
	Revision  int       `gorm:"column:revision;not null;uniqueIndex:idx_booking_mail_once"`
	DueAt     int64     `gorm:"column:due_at;not null;index"`
	Attempts  int       `gorm:"column:attempts;not null"`
	ClaimedAt *int64    `gorm:"column:claimed_at"`
	SentAt    *int64    `gorm:"column:sent_at"`
	CreatedAt time.Time `gorm:"column:created_at;not null"`
}

// models.go, on Person after Zone:
Handle *string `gorm:"column:handle;uniqueIndex"`

// seal/seal.go
PurposeBookingTitle        = "booking.title"
PurposeBookingDescription  = "booking.description"
PurposeBookingAvailability = "booking.availability"
PurposeBookingToken        = "booking.token"
PurposeBookerName          = "booking.booker.name"
PurposeBookerAddress       = "booking.booker.address"
PurposeBookerNote          = "booking.booker.note"
PurposeBookingIP           = "booking.ip"

// store_booking_page.go
type BookingPageInput struct {
	Title, Description, Availability               string
	DurationMin, BufferMin, NoticeMin, HorizonDays int
}
type BookingPageView struct {
	BookingPage
	Title, Description, Availability string
	Windows                          []Window
	Published                        bool
}
var ErrHandleTaken = errors.New("calendar: handle taken")

func ValidateHandle(h string) error                       // ^[a-z0-9-]{2,32}$ after lowercasing; not m/new/static
func ValidateBookingPageInput(in *BookingPageInput) error // trims; len(title) 1..100 BYTES; len(desc) <= 1000 BYTES; duration 5..480; buffer 0..120; notice 0..10080; horizon 1..90
func newPageCode() string                                 // 8 × [a-z0-9], crypto/rand
func (s *Store) SaveHandle(ctx, personID int64, handle string) error
func (s *Store) PersonByHandle(ctx, handle string) (Person, error)
func (s *Store) CreateBookingPage(ctx, personID int64, in BookingPageInput, windows []Window) (BookingPage, error)
func (s *Store) UpdateBookingPage(ctx, personID, pageID int64, in BookingPageInput, windows []Window) error
func (s *Store) PublishBookingPage(ctx, personID, pageID int64, publish bool) error
func (s *Store) BookingPages(ctx, personID int64) ([]BookingPageView, error)
func (s *Store) BookingPage(ctx, personID, pageID int64) (BookingPageView, error)
func (s *Store) PublicBookingPage(ctx, handle, code string) (BookingPageView, Person, error)

// testing.go
func (a *App) StoreForTest() *Store
func (a *App) PersonByAddressForTest(t TB, address string) Person
  • [ ] Step 1: Failing tests — booking_store_test.go: TestABookingPageRoundTripsSealedWithItsWindows (8-char code; unpublished → NotFound; publish → visible with Published; sealed grep for title/description/availability), TestUnknownHandleUnknownCodeAndUnpublishedAllVanishTheSameWay, TestAHandleIsUniqueLowercaseAndNotReserved ("Paul" saves as paul; second claim ErrHandleTaken; m, new, static, a, has space, üñí refused; "" clears), TestAnotherPersonCannotReadEditOrPublishMyPage (all three NotFound), TestPageLimitsAreBytes (a 101-byte title of 34 three-byte runes is refused; a 100-byte one accepted).
  • [ ] Step 2: Run; compile failure.
  • [ ] Step 3: Models, purposes, Handle, Models registration (all four new models), testing.go hooks.
  • [ ] Step 4: Generate the migration; check. Inspect: handle nullable + unique; ip_fp nullable; published_at nullable; token_sealed blob; the four-column unique on booking_mails.
  • [ ] Step 5: store_booking_page.go. Every scoped call is Where("id = ? AND person_id = ?"); miss → ErrNotFound. Updates by explicit map. Windows deleted + reinserted in the same transaction. PublicBookingPage: PersonByHandle → First(&page, "person_id = ? AND code = ? AND published_at IS NOT NULL") → open → windows ordered.
  • [ ] Step 6: Pass. make ci. Commit booking: pages, windows, bookings and their mail — the tables, sealed where they are content.

---

Task 2: Zone() made safe; availability parser; pane

Spec §3, §5.1, §9.

Produces:

func ParseAvailability(text string) (windows []Window, problems []string)
func MergeWindows(ws []Window) []Window
func DescribeWindows(ws []Window) []string
type BookingPaneView struct{ Lines, Problems []string; Empty bool }
func bookingPaneFor(text string) BookingPaneView
func IsCommonZone(name string) bool            // zones.go
func ZoneCacheSizeForTest() int                // testing.go
  • [ ] Step 1: zone_test.go: 100 goroutines resolve Nowhere/TownN and Europe/Dublin; run with -race; afterwards ZoneCacheSizeForTest() <= len(CommonZones)+5.
  • [ ] Step 2: clock.go: sync.Map, successes only. zones.go: IsCommonZone.
  • [ ] Step 3: avail_test.go — the reading table (every §3.1 example plus: sun-tue 9-10; monday 9-12, monday 10-13 → one 09:00–13:00; monday 9-12, 12-1 → one; monday 22:00-24:00 → {Monday, 1320, 1440}; monday 0-1am; 12:30-2:30 under tuesdays → 12:30–14:30; 9pm-11 → 21:00–23:00), the refusing table (10pm-2, whenever, leading 12-3 with no day, 25-26, 9:60-10, 13pm-2pm, 24:00-1, 9-9, 9-5 in the office quoting in the office; two bad clauses → two problems), and DescribeWindows.
  • [ ] Step 4: Implement avail.go + avail_describe.go (a _windows.go suffix is a GOOS build constraint; Go would skip the file on Linux):
var (
	reAvailSplit    = regexp.MustCompile(`\s*[,;]\s*`)
	reAvailGroup    = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(weekdays|weekends|every\s+day|daily|all\s+days)\b`)
	reAvailDayRange = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?` + rangeSep + `(` + weekdayAlt + `)s?\b`)
	reAvailDay      = regexp.MustCompile(`(?i)^\s*(?:every\s+)?(` + weekdayAlt + `)s?\b`)
	reAvailRange    = regexp.MustCompile(`(?i)\b(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:[-–—]|to|until|till)\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\b`)
	reAvailFiller   = regexp.MustCompile(`(?i)\b(between|from|and|on|at)\b`)
)

// readClock validates RAW components; nothing is clamped.
//   meridiem != "": hour 1..12, minute 0..59; 12am → 0, 12pm → 12.
//   meridiem == "": hour 0..24 (24 only with minute 0 and only when atEnd), minute 0..59;
//                   if inferPM and 1 <= hour <= 7, hour += 12 — on the HOUR COMPONENT, whatever the minutes.
func readClock(hh, mm, meridiem string, inferPM, atEnd bool) (minutes int, ok bool)

Range rule: sm, em := m[3], m[6]; if exactly one is set, the other inherits it; inferPM := sm == "" && em == "". end <= start → problem quoting m[0]. Every unconsumed non-filler token → problem quoting the clause. MergeWindows sorts by weekday then start and merges next.StartMin <= cur.EndMin.

  • [ ] Step 5: _bookingpane.html (as in revision-2 plan) into fragments and every page ParseFS.
  • [ ] Step 6: Pass. make ci. Commit booking: one parser for "mondays 12–3", and Zone() stops caching garbage.

---

Task 3: The range-query fix; slot arithmetic

Spec §4, §4.1, §4.2 (day validation only; the bucket is Task 6).

Produces:

type Slot struct{ StartUTC, EndUTC int64; Local time.Time }
type Busy struct{ StartUTC, EndUTC int64 }
type SlotRule struct{ DurationMin, BufferMin, NoticeMin, HorizonDays int; Zone string; Windows []Window }
func ComputeSlots(rule SlotRule, busy []Busy, now, from, to time.Time) ([]Slot, error)
func (s *Store) BusyFor(ctx, hostID int64, from, to int64, now time.Time, excludeEventID int64) ([]Busy, error)
func (s *Store) FreeSlots(ctx, page BookingPageView, host Person, now, from, to time.Time, excludeEventID int64) ([]Slot, error)
func ruleFor(page BookingPageView, host Person) SlotRule
  • [ ] Step 1: The range regression first — occurrence_test.go, TestAMovedOccurrenceOfAFinishedSeriesStillShows: create a daily series with repeat_end (check the form's field name in event_form.html) three days from now; move its LAST occurrence (scope=this) to ten days from now; GET /day/<that day> must show the chip. Run: FAIL (the override is dropped).
  • [ ] Step 2: Fix store_range.go: after collecting masterIDs from masters, also add *r.SeriesID for every row with r.SeriesID != nil whose master is not already listed. Pass.
  • [ ] Step 3: slots_test.go (helpers dublin, mon = 2026-09-07 Dublin, mondayRule(dur, buffer) with 12:00–15:00 Monday, starts(), eq()): - step-by-duration → 12:00, 13:00, 14:00 - busy 13:00–13:30 → 12:00, 14:00 - buffer 10 → 14:00 - busy 11:50–11:55 with from = 12:00, buffer 10 → 13:00, 14:00 - notice floor: NoticeMin: 0, now = 11:55 → 12:00 is NOT offered (MinNotice is 10 min) - notice 120, now 11:00, horizon 7, to = +14d → Mon 13:00, Mon 14:00 only - to bounds start: rule window Monday 23:00–24:00, 60-min, to = Tuesday 00:00 local → the 23:00 slot IS offered - spring-forward 2026-03-29 with 00:00–04:00 / 60: no slot straddles 01:00; every offered start and end satisfies ToUTC(FormatLocal(x)) == x.Unix(); pin the list once observed - fold 2026-10-25 with 00:00–04:00 / 60: same assertions; additionally assert (EndUTC-StartUTC) == 3600 for every slot - Tokyo host Monday 09:00 → LA Sunday
  • [ ] Step 4: Implement slots.go:
for m := w.StartMin; m+rule.DurationMin <= w.EndMin; m += rule.DurationMin {
	start := time.Date(day.Year(), day.Month(), day.Day(), 0, m, 0, 0, loc)
	end := start.Add(dur)
	if !wallIs(start, day, m) || !wallIs(end, day, m+rule.DurationMin) { continue }
	if !roundTrips(start, rule.Zone) || !roundTrips(end, rule.Zone) { continue }
	if start.Before(earliest) || start.Before(from) || !start.Before(to) || end.After(latest) { continue }
	if overlapsAny(start.Add(-pad).Unix(), end.Add(pad).Unix(), busy) { continue }
	out = append(out, Slot{start.Unix(), end.Unix(), start})
}
// wallIs: t is on `day`'s local date (or, when minutes == 1440, the next date at 00:00) and t.Hour()*60+t.Minute() == minutes%1440.
// roundTrips: u, err := ToUTC(FormatLocal(t, false), zone, false); err == nil && u == t.Unix().
// earliest := now.Add(max(MinNotice, NoticeMin)); latest := now.In(loc).AddDate(0,0,HorizonDays).

store_slots.go: envelope ±1 day; for all-day, evLoc := zoneOrUTC(e.TZ); first := time.Unix(e.OccStartUTC,0).In(evLoc); last := time.Unix(e.OccEndUTC-1,0).In(evLoc); for each date in [first, last] add DayBounds(date, host.Zone). Lapsed holds: SELECT event_id FROM bookings WHERE state='held' AND hold_expires_at <= ? → skip set.

  • [ ] Step 5: Pass; make ci; commit booking: free slots — the host's windows minus everything the host can see, and the range query stops losing moved occurrences.

---

Task 4: The state machine, the mail queue, the sweep queries, the host's authority

Spec §5.2–5.7 (store), §2.5, §6 (queue), §7.1 (counts), §8 (queries), §5.5 (host authority).

Produces:

// store.go
type Store struct { g *gorm.DB; seal *seal.Sealer; logger *slog.Logger; holdMu sync.Mutex }

// store_booking_flow.go
var (
	ErrSlotTaken   = errors.New("calendar: slot taken")
	ErrOverCap     = errors.New("calendar: too many open holds")
	ErrBookedEvent = ValidationError{"repeat", "This is a booked meeting; it cannot repeat."}
)
type HoldInput struct {
	Page             BookingPageView
	Host             Person
	StartUTC, EndUTC int64
	Name, Address    string
	Note, BookerZone string
	IPFP             string
}
func (s *Store) HoldBooking(ctx, in HoldInput, now time.Time) (Booking, error)                              // holdMu; caps; slot; tx incl. mail row; token returned via view
func (s *Store) ConfirmBooking(ctx, bookingID int64, now time.Time) (changed bool, err error)               // guard: held, expires > now, now < event.start
func (s *Store) RescheduleBooking(ctx, bookingID, startUTC, endUTC int64, rev int, now time.Time) (changed bool, err error) // holdMu; ErrOverCap; ErrSlotTaken; rev mismatch → changed=false
func (s *Store) CancelBooking(ctx, bookingID int64, by string, now time.Time) (changed bool, err error)
func (s *Store) cancelBookingsForEvent(tx *gorm.DB, eventID int64, by string, now time.Time) error       // used by UpdateEvent/DeleteEvent

// store_booking_read.go
type BookingView struct {
	Booking
	Page                BookingPageView
	Host                Person
	HostName            string
	Event               Event     // the ROW, for Status/StartUTC/EndUTC/StartLocal/EndLocal/TZ/Sequence/DeletedAt only
	EventUID            string
	Name, Address, Note string
	Token               string    // opened; empty unless withToken
}
type HoldCounts struct{ OpenIP, OpenPage, OpenAddress, AttemptsIP, AttemptsAddress int }
func (s *Store) BookingByToken(ctx, token string) (BookingView, error)
func (s *Store) BookingByID(ctx, id int64, withToken bool) (BookingView, error)
func (s *Store) HoldCountsFor(ctx, pageID int64, ipFP, addressFP string, now time.Time) (HoldCounts, error)
func (s *Store) bookingIDForEvent(ctx, eventID int64) (int64, bool)

// store_booking_mail.go
func queueBookingMail(tx *gorm.DB, bookingID int64, kind string, recipients []string, revision int, now time.Time) error
func (s *Store) ClaimBookingMail(ctx, now int64) (BookingMail, bool, error)            // lease = claimLease; oldest due first
func (s *Store) MarkBookingMailSent(ctx, id int64, now int64) error                    // WHERE id=? AND sent_at IS NULL
func (s *Store) MarkBookingMailFailed(ctx, id int64, now int64) error                  // attempts+1; at MaxReminderAttempts sent_at = MailGaveUp
func (s *Store) NextBookingMailDue(ctx) time.Time
func (s *Store) UnsentBookingMailForTest(bookingID int64) []BookingMail

// store_booking_sweep.go
func (s *Store) ExpireHolds(ctx, now time.Time, limit int) (int, error)
func (s *Store) ForgetOldIPs(ctx, now time.Time, limit int) (int, error)
func (s *Store) NextHoldExpiry(ctx) time.Time
func (s *Store) NextIPForget(ctx) time.Time
  • [ ] Step 1: Tests — booking_flow_test.go (helpers hostWithPage, slotsFor) and booking_mail_test.go: - hold: tentative event titled "Chat — Doc"; HoldExpiresAt == min(now+30m, start) — test both branches with a slot 5 minutes out (notice 0 but MinNotice 10 → use 15 minutes out: expiry = start); one unsent MailHold/ToBooker row; token not on disk; BookingByToken(token) opens the same row and BookingByID(id, true).Token == token. - 20 goroutines, one slot, one winner (-race). - confirm: first changed; second not; event confirmed Sequence == 1; attendee; default reminder when host has one; one MailConfirmed/ToHost row at revision 1. - confirm after hold_expires_at → no change; confirm when now >= event.StartUTC → no change (age the event via DBForTest). - reschedule with correct rev → changed, sequence 2, Reschedules 1, two MailRescheduled rows at rev 2; the same call again with the OLD rev → changed=false, nothing else moved; back to the original slot works; sixth → ErrOverCap. - cancel by booker: changed; event cancelled, sequence bumped; ReminderQueue rows gone; unsent MailRescheduled rows deleted; two MailCancelled rows; CancelledBy == booker; second → no change. - host UpdateEvent with Status: cancelled → booking cancelled by host, one MailCancelled/ToBooker; host DeleteEvent → same plus sequence bumped on the deleted row; a held booking on a deleted event → expired. - UpdateEvent with RRule on a booked event → ErrBookedEvent. - mail queue: ClaimBookingMail twice in a row returns the same row only after claimLease; MarkBookingMailSent is conditional; MarkBookingMailFailed × MaxReminderAttempts → SentAt == MailGaveUp; NextBookingMailDue is zero when nothing is unsent. - sweep: ExpireHolds → expired, event soft-deleted, queue rows gone, slot free; NextHoldExpiry; ForgetOldIPs at ip_forget_at; NextIPForget. - HoldCountsFor: three holds one IP → OpenIP 3, AttemptsIP 3; expire → OpenIP 0, AttemptsIP 3. - NextDueAt returns each of the four sources when it is earliest. - reminder claim path: a queue row whose event is cancelled is dropped (ClaimDueReminder returns ok=false and deletes it).
  • [ ] Step 2: Run; failure.
  • [ ] Step 3: Implement. Sketches:
func (s *Store) HoldBooking(ctx context.Context, in HoldInput, now time.Time) (Booking, error) {
	s.holdMu.Lock()
	defer s.holdMu.Unlock()
	addrFP, _ := s.seal.Fingerprint(seal.PurposeAttendeeLookup, normalizeAddress(in.Address))
	c, err := s.HoldCountsFor(ctx, in.Page.ID, in.IPFP, addrFP, now)
	if err != nil { return Booking{}, err }
	if c.OpenIP >= 5 || c.OpenPage >= 20 || c.OpenAddress >= 2 || c.AttemptsIP >= 20 || c.AttemptsAddress >= 6 { return Booking{}, ErrOverCap }
	loc, _ := Zone(in.Host.Zone)
	d := time.Unix(in.StartUTC, 0).In(loc)
	day := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, loc)
	slots, err := s.FreeSlots(ctx, in.Page, in.Host, now, day, day.AddDate(0, 0, 1), 0)
	if err != nil { return Booking{}, err }
	if !hasStart(slots, in.StartUTC) { return Booking{}, ErrSlotTaken }
	token := newToken() // 32 bytes hex, as MintFeed
	var b Booking
	err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
		calID, err := s.defaultCalendarIDTx(tx, in.Host.ID)
		e, err := s.buildEvent(in.Host.ID, EventInput{CalendarID: calID, Title: in.Page.Title + " — " + in.Name, Notes: in.Note,
			StartLocal: FormatLocal(time.Unix(in.StartUTC,0).In(loc), false), EndLocal: FormatLocal(time.Unix(in.EndUTC,0).In(loc), false),
			TZ: in.Host.Zone, Kind: KindMeeting, Status: StatusTentative})
		if err := tx.Create(&e).Error; err != nil { return err }
		expires := now.Add(HoldTTL).Unix(); if in.StartUTC < expires { expires = in.StartUTC }
		b = Booking{PageID: in.Page.ID, EventID: e.ID, TokenHash: HashFeedToken(token), TokenSealed: seal(token), State: BookingHeld, ..., HoldExpiresAt: expires, IPFP: &in.IPFP, IPForgetAt: now.Add(IPForgetAfter).Unix(), ...}
		if err := tx.Create(&b).Error; err != nil { return err }
		return queueBookingMail(tx, b.ID, MailHold, []string{ToBooker}, 0, now)
	})
	return b, err
}

ConfirmBooking: in one transaction read the event row (start_utc), then UPDATE bookings SET state='confirmed', confirmed_at=?, updated_at=? WHERE id=? AND state='held' AND hold_expires_at > ? AND ? < ? (now, start); RowsAffected == 0 → return false, nil; UPDATE events SET status='confirmed', sequence = sequence + 1, updated_at=? WHERE id=?; re-read sequence; SetAttendees (make a tx variant or call after commit — say which); queueBookingMail(tx, id, MailConfirmed, [ToHost], seq, now). After commit: EnsureDefaultReminder(ctx, host, event).

RescheduleBooking: holdMu; read view; State != confirmed → false,nil; Reschedules >= MaxReschedules → ErrOverCap; FreeSlots(..., excludeEventID) with dur := Event.EndUTC - Event.StartUTC — the rule's DurationMin for this call is dur/60; slot present else ErrSlotTaken; tx: UPDATE events SET start_local, end_local, start_utc, end_utc, sequence = sequence + 1, updated_at WHERE id = ? AND sequence = ? (rev) → 0 rows → false, nil; UPDATE bookings SET reschedules = reschedules + 1, updated_at WHERE id = ? AND state = 'confirmed'; queueBookingMail(... MailRescheduled, [ToBooker, ToHost], rev+1 ...); after commit: RescheduleReminder for each RemindersFor(host, eventID).

CancelBooking(by): tx: UPDATE bookings SET state='cancelled', cancelled_at, cancelled_by=?, updated_at WHERE id=? AND state='confirmed' → 0 → false,nil; UPDATE events SET status='cancelled', sequence = sequence + 1; DELETE FROM reminder_queues WHERE reminder_id IN (SELECT id FROM reminders WHERE event_id = ?); DELETE FROM booking_mails WHERE booking_id = ? AND kind = 'rescheduled' AND sent_at IS NULL; queue MailCancelled to both (or booker only when by == host).

cancelBookingsForEvent(tx, eventID, by, now): the same as the confirmed branch for state='confirmed' bookings, and UPDATE bookings SET state='expired' WHERE event_id=? AND state='held'. Called from UpdateEvent when in.Status == StatusCancelled && existing.Status != StatusCancelled, and from DeleteEvent (which also does sequence = sequence + 1). UpdateEvent: if in.RRule != "" { if _, booked := s.bookingIDForEvent(ctx, eventID); booked { return ErrBookedEvent } }; change "sequence": existing.Sequence + 1 to gorm.Expr("sequence + 1").

store_drain.go ClaimDueReminder: after loading e, if e.Status == StatusCancelled { delete queue row; return DueReminder{}, false, nil }.

NextDueAt: min over four non-zero times.

  • [ ] Step 4: go test -race ./internal/calendartest/ -run 'Hold|Confirm|Reschedule|Cancel|Booked|Expire|Mail|Forget|NextDue|Claim' -count=1; pass. make ci; commit booking: held → confirmed → cancelled as conditional updates, with mail as a queue the drain owns.

---

Task 5: Host settings UI

Spec §2.1, §3.4, §7.4. Unchanged from revision 2 except: the publish button and its sentence (§7.4); byte limits on title/description in the form's server-side validation message ("Keep it under 100 bytes" is unfriendly — say "Keep it shorter" and rely on the validator); the list shows Published/Not published.

Routes (signed-in group): GET /settings/booking, GET /settings/booking/new, POST /settings/booking, GET /settings/booking/{id}, POST /settings/booking/{id}, POST /settings/booking/{id}/publish (publish=1|0), POST /settings/booking/parse.

  • [ ] Step 1: Tests — booking_settings_test.go: page made and listed as not published with no URL; publish → URL /book/paul/<8 chars> and the §7.4 sentence present; unreadable sentence → 422 quoting it, nothing saved; pane fragment; another person → 404 on GET/POST/publish; taken handle → 422 "taken".
  • [ ] Step 2–3: Implement as in the revision-2 plan (handlers, bookingPageForm, templates, booking.js, CSS). settings.html gains the handle field and a link to /settings/booking. booking.js also does the public page's zone preselect ([cal-book-zone] without data-chosen).
  • [ ] Step 4: Pass; make ci; commit booking: the host's side — a handle, pages, a pane, and a Publish button that says what it does.

---

Task 6: The public flow, mail, ICS, the timed sender, the token bucket

Spec §4.2, §5, §6, §7.1–7.2, §2.7.

Routes (chi router, outside the session group, /book/m/... registered BEFORE /book/{handle}/{code}):

GET  /book/m/{token}                    held: confirm button; confirmed: manage; else: gone
POST /book/m/{token}/confirm
GET  /book/m/{token}/calendar.ics
GET  /book/m/{token}/reschedule         ?day=&zone=   (+ hidden rev)
POST /book/m/{token}/reschedule         start, zone, rev
POST /book/m/{token}/cancel
GET  /book/{handle}/{code}              ?day=&zone=
POST /book/{handle}/{code}              start, zone [, name, address, note]

Produces:

// handlers_booking_util.go
func clientIP(r *http.Request) string
func (h *handlers) publicGate(next http.HandlerFunc) http.HandlerFunc // token bucket 60/min per ip_fp → 429; sets no-store + no-referrer; wraps every public route
func notFoundLikeTheRouter(w, r)
func zoneChoices(host Person) []string          // CommonZones ∪ {host.Zone}
func pickZone(r *http.Request, host Person) (zone string, chosen bool)
type bucket struct{ mu sync.Mutex; tokens map[string]*tb } // 60 tokens, refill 1/s, prune idle keys past 5 min

// mail_timed.go
var ErrMailTimeout = errors.New("calendar: mail send timed out")
type timedSender struct{ next mail.Sender; limit time.Duration }
func (t timedSender) Send(ctx, to, subject, body string) error // goroutine + select on time.After(limit); the request WAITS here

// booking_mail.go
func (h *handlers) sendBookingMail(ctx context.Context, m BookingMail) error // loads BookingByID(withToken=true), renders by kind+recipient, sends via h.mailer (already wrapped), marks sent/failed
func (h *handlers) trySendNow(ctx context.Context, bookingID int64)          // claims and sends every unsent row for the booking, once; errors only logged (scrubbed)

// booking_ics.go
func WriteBookingICS(v BookingView) string // METHOD on VCALENDAR; one VEVENT; allowlisted fields only; no URL:

New wraps the configured mailer: h.mailer = timedSender{next: mailer, limit: 20 * time.Second} (and the recorder stays outermost so tests still see bodies — check the order in buildMailer).

  • [ ] Step 1: Tests — booking_http_test.go (helpers: publishedPage(t) returning app, mailer, path, host; strangerGet/strangerPost with optional RemoteAddr; reStart, reManage, reRev = regexp.MustCompile(name="rev" value="(\d+)"); extend pickyMailer with to matching and a delivered []struct{To, Body string}): - two-step hold; one mail to booker; GET link → button, state still held, still one mail; POST confirm → manage, host mailed once; second POST → nothing. - confirm after event.start_utc (age via SQL) → page says expired/too late, no change. - ICS: METHOD:REQUEST on the VCALENDAR line; SUMMARY:Quick chat; ATTENDEE;CN=Doc;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:doc@tito.io; ORGANIZER; mailto:host@example.com; SEQUENCE:1; no URL:; ParseICS(strings.NewReader(body), "UTC") yields one event. - host edits never reach the booker (note, location, second attendee → absent from manage, ICS, cancelled mail). - same slot twice → 409; wrong token → byte-identical 404; expired says expired. - reschedule: GET has rev; POST with it → 2 mails, SEQUENCE:2; the same POST again → 200, no change, no mail; cancel → METHOD:CANCEL, 2 mails; again → nothing. - host cancels via edit → booker mailed, page says cancelled by host; host deletes → same, ICS sequence higher. - hold mail refused (refuse = "bounce@example.com"): 200 "Check your mail" with the lapse sentence; the slot is STILL held (a second stranger gets 409); the mail row is unsent with attempts 1. - no-store and no-referrer on every public response. booking_abuse_test.go: sixth open hold per IP 429; third per address 429; 21st attempt in 24h 429 with none open (use refused mails so none confirm; they still count); body > 16 KiB → 422; ?zone=Nowhere/Town falls back to host zone; ?day= past horizon → 404; 61 GETs in a minute from one IP → the 61st is 429 (use a test hook ResetPublicGateForTest). timedSender: a stallingMailer that blocks on a channel → Send returns ErrMailTimeout within limit+1s (set limit small via a constructor).
  • [ ] Step 2: Run; failure.
  • [ ] Step 3: Implement per spec §5. Hold response: always 200 book_sent; when trySendNow reported an error for the hold row, Data.Unsure = true adds the lapse sentence. Manage page template receives only allowlisted fields (build a publicBooking struct with exactly: Title, Description, StartUTC, EndUTC, Status, HostName, HostAddress, Name, Address, CancelledBy, Zone, HostZone, Rev — never pass BookingView to a public template).
  • [ ] Step 4: Pass; make ci; commit booking: a stranger holds a slot, a button confirms it, and the ICS is one click away.

---

Task 7: Drain ordering, leak and log tests, browser passes

Spec §8, §9.

  • [ ] Step 1: Tests — booking_sweep_test.go: tick expires and frees; tick sends owed mail (refuse then un-refuse); reminders still progress with 30 unsent booking-mail rows queued (drainDeadline halves — fake by making the mailer slow: a slowMailer sleeping 100 ms; assert a due reminder was sent in the same tick); DrainOnRequest does exactly one thing (one reminder OR one mail OR one expiry per GET); ip_fp nulled at ip_forget_at; NextDue reflects each source. Log test with a buffer logger: no address, no 64-hex token. Leak fixture in TestTheDatabaseNeverHoldsAPlaintextTitle (seven needles including the token).
  • [ ] Step 2: Implement drain: ExpireHolds(200) → reminder loop until now+deadline/2 → booking mail claim loop until deadline → ForgetOldIPs(500). DrainOnRequest: reminder if claimable, else one booking mail, else one expiry.
  • [ ] Step 3: Browser — test/browser/booking.mjs with subcommands hold <origin> <path> and confirm <origin> <link> (JS off, no cookie) and pane <origin> <cookieName> <cookieValue> (JS on). TestBookingInARealBrowser in playwright_test.go creates host/handle/page/publish via the store, runs hold, reads LastMailForTest, extracts the link, runs confirm, then pane. Makefile/package.json → -run InARealBrowser.
  • [ ] Step 4: make ci and make browser green; commit booking: holds expire on the tick, owed mail is retried, reminders keep their half, and a stranger with no script can book and confirm.

---

Task 8: AGENTS.md

Spec §11, verbatim: rows B1, B2, B4, B5, B8, B11–B17; the Frontend, two Data, Ticks (rename + two bullets), two Time bullets; leak-list item 5. Commit docs: the rules a booking page adds.

---

Self-review

  • Spec coverage: §2 → T1 (all columns, byte limits, booking_mails); §2.6 → T1 scoping + T4 terms; §2.7 → T6 clientIP; §3 → T2, T5; §4 → T3 (four-part check, to bounds start, notice floor, all-day via Occ, lapsed holds, ±1 day); §4.1 → T3 step 1–2; §4.2 → T3 (day validation lives in T6's handler; the one-query rule in T6) + T6 bucket; §5.1–5.2 → T6; §5.3–5.4 → T6 + T4 guards; §5.5 → T4 (host authority) + T6 (allowlist struct); §5.6–5.7 → T4, T6; §6 → T4 queue + T6 render/timed + T7 retry; §7 → T4 counts, T6 429s + bucket, T7 forget; §8 → T7; §9 → each Step 1; §11 → T8.
  • Type consistency: Window once; BookingView.Event is the Event ROW (not EventView) so public code cannot reach opened notes by accident — the manage/ICS code builds publicBooking from allowlisted fields; HoldInput.Page BookingPageView; MailHold…, ToBooker/ToHost, CancelledBy* constants match the spec's column values; RescheduleBooking takes rev int and returns changed.
  • Placeholders: none. DST expectations are pinned by observing once and recording, the repository's convention.

---

Revision 4–6 addenda (binding over the tasks above)

Five review rounds after the plan was first written. Each item names the spec section that now governs. Where a task's text above disagrees with an addendum, the addendum wins; where an addendum disagrees with the spec, the spec wins.

Landed state

  • Task 1 is on the branch (6f76ba4): models_booking.go has Booking.TitleSealed, Booking.DescriptionSealed, BookingMail.ClaimID, and DefaultBufferMin/DefaultNoticeMin/DefaultHorizonDays. The store does NOT merge windows — callers pass MergeWindows(ParseAvailability(...)) output. PersonByAddressForTest runs EnsurePersonSetup. ValidateBookingPageInput does not default zero knobs.
  • Window lives in slots.go.

Task 3b — the remaining calendar fixes (spec §4.1, B23)

Do these in occurrences.go / store_range.go with regressions in occurrence_test.go BEFORE Task 4 depends on EventsInRangeStrict:

  1. daysBetween → difference of CIVIL dates: build both ends as time.Date(y, m, d, 0,0,0,0, time.UTC) and divide by 24h. Test: weekly Dublin event 2026-03-28 09:00 → 2026-03-30 09:00 still occupies 30 March on its first and on a later occurrence.
  2. rangeRows master exclusion: events.until_utc + (events.end_utc - events.start_utc) + 90000 >= ?. Test: UNTIL the night before the fold, final occurrence spanning the fold day, day query on that day shows it.
  3. ExpandComplete(...) ([]Occurrence, bool) — the existing body, returning false when the 100,000 guard fired; Expand calls it and drops the flag. EventsInRangeStrict(ctx, personID, from, to) — same as EventsInRange but returns ErrIncompleteExpansion (a package var) if any master's expansion was incomplete. Test: a daily rule with start_local in 1700 (insert via DBForTest) → strict errs for a 2026 window, tolerant answers.

Task 4 addenda (spec §2.4, §2.5, §5.4–5.7, §6, §8, B18, B22, B24)

  • BusyFor is func (s *Store) BusyFor(ctx, host Person, from, to int64, skip map[int64]bool) ([]Busy, error) (Task 3 agent's signature) and MUST use EventsInRangeStrict. FreeSlots(ctx, page, host, now, from, to, excludeEventID) builds skip = {excludeEventID} ∪ {eventid : bookings.state='held' AND holdexpires_at <= now} and calls ComputeSlots(ruleFor(page, host), busy, now, from, to); a DurationMin override is needed for reschedule (the EVENT's duration), so give FreeSlots a durationMin int parameter (0 = the page's).
  • Hold writes TitleSealed/DescriptionSealed on the booking from the page (B20). The event title is page.Title + " — " + name.
  • Reminder queue identity (B22): additive migration adds claim_id TEXT (nullable) and retry_at INTEGER (nullable) to reminder_queues (ReminderQueue.ClaimID *string, RetryAt *int64; regenerate). ClaimDueReminder: eligibility fired_at IS NULL AND due_at <= ? AND (retry_at IS NULL OR retry_at <= ?) AND (claimed_at IS NULL OR claimed_at <= ?) with <=; the claim UPDATE sets claimed_at AND a fresh claim_id (16 random bytes hex) and the same predicate; DueReminder carries ClaimID. MarkReminderSent(ctx, queueID, claimID, now), FailReminder(ctx, queueID, claimID, occStart), new DeferReminder(ctx, queueID, claimID, now) (retry_at = now+30s, claimed_at = NULL, claim_id = NULL, attempts untouched), and RescheduleAfterFiring(ctx, reminderID, queueID, claimID, firedStart) are ALL WHERE id = ? AND claim_id = ? and check RowsAffected; mark + advance run in ONE transaction. rescheduleAfter (any path that rewrites a queue row) clears claim_id, claimed_at, retry_at. Never write due_at from a retry. NextDueAt reminders term: MIN(MAX(due_at, COALESCE(retry_at,0), COALESCE(claimed_at + 300, 0))) over fired_at IS NULL.
  • Reschedule (B24): inside the transaction, after the event UPDATE, for each Reminder on the event: recompute the queue row from the NEW start (a tx variant of rescheduleAfter — factor the body to take *gorm.DB), clearing claim_id/claimed_at/retry_at. Nothing reminder-related runs after commit.
  • Booking mail claim: ClaimBookingMail sets claimed_at = now, claim_id = <random>, due_at = now + claimLease WHERE id = ? AND sent_at IS NULL AND due_at <= now and returns the row with its ClaimID. MarkBookingMailSent(ctx, id, claimID, now): WHERE id=? AND claim_id=?. MarkBookingMailFailed(ctx, id, claimID): attempts+1, same predicate; at MaxReminderAttempts also sent_at = MailGaveUp. NEW DeferBookingMail(ctx, id, claimID, now): due_at = now+30s, claimed_at = NULL, claim_id = '', attempts untouched. NextBookingMailDue = MIN(due_at) WHERE sent_at IS NULL.
  • Supersession (B19): cancel and expire delete ALL unsent booking_mails for the booking (not just rescheduled); reschedule deletes unsent rescheduled rows before queueing.
  • Host authority (B18): UpdateEvent on a booked event (bookingIDForEvent): refuse if submitted StartLocal, EndLocal, TZ, AllDay, Kind or RRule differ from the stored row → ValidationError{"start", "This is a booked meeting. Its time is changed by the person who booked it, or by cancelling and letting them rebook."}; refuse Status other than confirmed/cancelled; refuse Status == confirmed when the booking is cancelled/expired → ValidationError{"status", "This booking was cancelled. Make a new event instead."}; refuse any update to a soft-deleted event. Replace ErrBookedEvent with these. Status → cancelled on a confirmed booking → cancelBookingsForEvent(tx, eventID, CancelledByHost, now).
  • cancelBookingsForEvent also deletes ALL unsent mail rows for the booking and queues (cancelled, booker, seq) only (the host did it).
  • ExpireHolds deletes unsent mail rows for the expired booking too.
  • Tests to ADD to Task 4's list: everything in spec §9 under Reminders, Mail queue (claim identity, defer keeps attempts 0, crash-after-claim leaves NextDueAt at lease end), Zone change, and the host-edit refusals (each of the six fields, reviving, deleted event, title-only succeeds).

Task 5 addenda (spec §5.5 B25, §7.4)

  • templates/event_form.html + readEventForm: when the event is booked (bookingIDForEvent), render start, end, all_day, kind, repeat controls disabled with the B18 sentence beside them, AND emit hidden inputs start, end, tz (the EVENT's TZ), all_day, kind, repeat carrying the stored values. readEventForm uses r.FormValue("tz") for TZ when non-empty (only the booked form emits it), else p.Zone. Test: scriptless title-only edit succeeds, and still succeeds after the host changes their profile zone.
  • The event page shows a booking badge ("Booked by {name}") and, when the booking is confirmed, the sentence "To move this meeting, cancel it and let {name} rebook."
  • Callers pass MergeWindows(...) output to the store (Task 1 does not merge).
  • Windows: the form's availability field is optional; an empty page offers nothing.

Task 6 addenda (spec §4.2, §5.1–5.8, §6, §7.2)

  • Public data: templates receive publicBooking (spec §5.5) built from BookingView: Title/Description from the BOOKING's snapshot (TitleSealed/DescriptionSealed), never the page's or the event's.
  • Semaphore (§4.2): var slotSem = make(chan struct{}, 4); public handlers select { case slotSem <- struct{}{}: defer release; default: 503 + Retry-After: 5 } around FreeSlots. ErrIncompleteExpansion from FreeSlots → 503 + Retry-After: 60.
  • Gate (§7.2): bucket capped at 10,000 keys, evict idle > 5 min on insert; full with no idle key → 429 + Retry-After: 60 for a NEW key.
  • boundedSender replaces timedSender: semaphore of 4 in flight (ErrMailBusy immediately when full), wait min(20s, ctx deadline remaining) → ErrMailTimeout. Wrap the configured sender in New so REMINDERS use it too (the recorder stays outermost). sendBookingMail: ErrMailBusy → DeferBookingMail; other error → MarkBookingMailFailed; nil → MarkBookingMailSent. sendReminder callers: ErrMailBusy → DeferReminder.
  • Hold response: always 200 book_sent; if the immediate try errored, add the lapse sentence. NEVER FailHold — that function does not exist.
  • ICS (§5.8): no CN= on ORGANIZER/ATTENDEE; METHOD from effective state (booking cancelled OR event cancelled OR event deleted → CANCEL); held/expired → 404; event row read including soft-deleted. SUMMARY = snapshot title.
  • Confirm guard includes now < events.start_utc (read inside the transaction).
  • Reschedule POST carries hidden rev; RescheduleBooking(..., rev, ...) writes tz = host.Zone with the four time columns (B21) and repairs reminder rows in the transaction (B24).
  • Mail templates say the CURRENT state; the confirmed-to-host template opens with "This booking has since been {state}." when the booking is no longer confirmed at render time.

Task 7 addenda (spec §8)

  • drain order: ExpireHolds(200) → ForgetOldIPs(500) → reminders until deadline/2 → booking mail until deadline (each send bounded by remaining budget via ctx). DrainOnRequest: one expiry if due, else one reminder, else one booking mail; 5-second ctx.
  • Tests: reminders still progress with 30 queued mail rows behind a slow sender; DrainOnRequest does exactly one thing; NextDueAt reflects retry_at and the lease for reminders.

Task 8 addenda

AGENTS.md changes are spec §11 as of revision 6 (rows B1, B2, B4, B5, B8, B11–B25; the Frontend, Data ×3, Recurrence ×2, Ticks ×3, Time ×2 bullets; leak-list item 5); plus the calendar spec's §7 debt line about mail.SMTP and its context.