oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

Booking pages — design

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). Revision 9. Reviewed eight times by GPT-6 (gpt-6-astra on the one run that exposed its identifier) through codex-cli 0.153.0; all eight returned REWRITE, each round narrower than the last. Rounds 7 and 8 found nothing in the booking design itself: every finding was a pre-existing bug in the recurrence engine the design depends on, and the spec owns them (§4.1). The spec rounds end here; the next review is of the code. §12 records every finding and disposition, including the two scoped rather than solved.

Asked for: a Calendly / Cal.com style "book time with me" page to share with people outside the team. The host sets availability in plain text — "mondays between 12–3, tuesdays 12–2, 5-7" — and it just figures it out. The booker gets a meeting invitation as ICS, and can reschedule or cancel.

This document is binding on the implementation. Where it disagrees with 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 title, a duration, an availability sentence, and three knobs with defaults. It is public at /book/{handle}/{code}, where the code is generated. 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. A hold that is not confirmed expires — after thirty minutes, or when the meeting would have started, whichever is first — and is swept on the existing tick.

---

1. Rulings made here

| # | Ruling | Rejected | Why | |---|---|---|---| | B1 | A booking is confirmed by a button on the page the mailed link opens — a same-origin POST — never on submit and never on GET | Instant confirmation; confirming on the GET | A GET that confirms is confirmed by every link-scanning mail gateway (r1 f2). The mailed link is a request; the button is the consent. | | B2 | Everything the host can see is busy | Personal only | Nobody gets booked over the team standup. | | B3 | Pages are per person, any number | One page | "15 min chat" and "60 min call" differ. | | B4 | Public identifier: chosen plaintext handle + GENERATED page code | A chosen slug | A slug is prose joined to events (r1 f1). | | B5 | Booking mail is the ONE outbound to a non-team address, and only to an address the recipient typed in order to receive it | Emailing quick-add attendees | The booker asked for the mail. | | B6 | The ICS is a download at a tokenised URL, not an attachment, and carries NO management link | Attachment; URL: | mail.Sender is plain text; a capability inside an ICS syncs everywhere (r1 f6). | | B7 | ONE availability parser, in Go | JS parser | The standing rule. | | B8 | A held slot is a tentative EVENT | A holds table | The range query already subtracts events. | | B9 | Slots are computed in the HOST's zone and rendered in the booker's | Booker's zone | The windows are the host's hours. | | B10 | Unknown token → 404, never 403 or "expired" | Distinguishing | No enumeration. | | B11 | The EVENT's sequence is the one revision counter, bumped as sequence = sequence + 1, and every booking-side write names the revision it expects | A booking sequence; stale +1 | Drift (r1 f11); lost bumps (r2 A11); replayed forms (r2 A10). | | B12 | Hold and reschedule serialise on a process-wide mutex AND take SQLite's write lock before the final busy read, inside the transaction that inserts | The mutex alone; a busy read before BEGIN | The mutex orders booking requests against each other; it does not order them against an ordinary CreateEvent by the host, which can land between a busy read and the insert (r7 f2). A no-op write at the top of the transaction acquires the RESERVED lock, so every other writer waits until the hold commits. | | B26 | Reschedule enumerates the event's reminder RULES and upserts a queue row for each from the new time | Rebuilding the queue rows that exist | A reminder that already fired has no queue row; rebuilding "every row" schedules nothing for tomorrow's moved meeting (r7 f3). | | B13 | Mail is a QUEUE TABLE, one row per (booking, kind, recipient, revision), claimed with an identity and a lease, acknowledged conditionally on that identity | A marker column; anonymous claims | One marker loses a cancellation mid-send (r2 B2); an old worker must not acknowledge a newer claim (r3 f6). | | B14 | The token is 32 bytes from crypto/rand, hex-encoded, stored SEALED as well as hashed | Hash only; derived tokens | The queue must rebuild the link (r2 B1); a derived token is a guessable one (r3 f11). | | B15 | A hold expires at the EARLIER of thirty minutes and the meeting's start; confirmation requires now < start | Thirty minutes flat | A five-minute meeting confirmed after it ended (r2 B5). | | B16 | No sender error is "definite." Nothing is freed because a send failed | Marking holds failed | smtp.SendMail can fail after acceptance (r2 A12). | | B17 | A slot is the same length on the wall clock and the elapsed clock, or it is not offered | Round-tripping the start only | The fold makes a two-hour hour (r2 A13). | | B18 | A booked event's SCHEDULING is owned by the booking flow. The host's form may change title, notes, location, and status to cancelled; it may NOT change start, end, tz, allday, kind or rrule, and may not revive a terminal booking. UpdateEvent refuses, naming the booking | Invariant checks on host-supplied times | Invariants were not enough: a stale form re-saved with an old 09:00 passes every invariant and moves a rescheduled meeting back (r4 A5); a kind change to note makes a confirmed booking non-busy (r4 B1). Locking the fields closes both. | | B19 | Mail says the CURRENT state, worded for it, and a transition deletes every unsent row the new state makes false | Historical snapshots | Snapshots need a sealed body per row and per-booking serialised delivery (r3 f4); current-state wording needs neither and is what a reader wants to know. What has already reached the sender cannot be recalled, and the spec says so. | | B20 | The booker sees the page's title and description AS THEY WERE at hold time, snapshotted sealed onto the booking | Live page text | Live text changes the ICS without changing its SEQUENCE (r3 f10). | | B21 | Reschedule writes tz = the host's current zone atomically with both local strings and both UTC columns | Leaving tz as it was | A host who moved zones would get Tokyo strings on a Dublin record (r3 f2). | | B22 | **Both queues carry a random `claimid; every completion mutation — ack, fail, defer, give-up, advance — is conditional on the claimid the worker holds and is built from the ROW THE CLAIM RETURNED, never from a candidate read; ErrMailBusy defers without counting an attempt. Reminders keep their semantic dueat and retry through retryat; booking mail's dueat is retry eligibility and nothing else** | Claim by ID or by claimedat; retry time written into dueat | A crash after a claim advertised work nobody could take (r4 A6); four stalled sockets burned every other message's attempts (r4 B4); a reminder finishing during a reschedule deleted the replacement (r4 B3); a whole-second claimedat is not an identity (r5 f1); a reminder's dueat IS the occurrence minus the offset, so writing a retry time into it moved the meeting (r5 f3). | | B24 | **Reschedule repairs the event's reminder queue rows INSIDE its transaction** | RescheduleReminder after commit | A crash between the two leaves a queue row that sends a reminder for a time the meeting no longer has (r5 f2). | | B25 | **A booked event's form carries its scheduling snapshot as hidden fields — including the event's own tz — with the visible controls disabled** | Disabled controls alone | Disabled controls submit nothing, so a title-only edit 422s; and the reader's TZ = p.Zone fails the comparison after a profile zone change (r5 f4). | | B23 | **Availability FAILS CLOSED**: if any busy series in the window could not be expanded to completion — truncated OR carrying a rule this app cannot parse — the page is 503, never "free" | Trusting Expand's slice | Expand` stops at 100,000 steps and returns an ordinary slice; a forever-daily rule from 1700 contributes no busy time to 2026 (r4 B5). |

---

2. Data

All migrations additive, generated.

2.1 people.handle

Nullable, unique, plaintext, [a-z0-9-]{2,32}, lowercased on save. Reserved: m, new, static. Changing it changes every public URL and the settings page says so.

2.2 booking_pages

id, person_id (idx), code, title_sealed, description_sealed,
duration_min, buffer_min, notice_min, horizon_days,
availability_text_sealed, published_at (nullable), created_at, updated_at
UNIQUE (person_id, code)
  • code: eight of [a-z0-9], generated, immutable, plaintext. Not a secret — it is in a shared URL — and distinct from the management token (§2.4).
  • title_sealed ≤ 100 bytes; description_sealed ≤ 1000 bytes. Byte limits, because the event validator's are bytes (r2 B6).
  • duration_min 5–480; buffer_min 0–120 (default 10); notice_min 0–10080 (default 120) with a floor of 10 at slot time (B15); horizon_days 1–90 (default 30).
  • published_at: NULL is unpublished → 404. Publishing is explicit (§7.4).

2.3 booking_windows

id, page_id (idx), weekday (0=Sunday..6), start_min, end_min

Plaintext (shape-of-time). 0 <= start_min < end_min <= 1440. Rewritten wholesale on save, merged first.

2.4 bookings

id, page_id (idx), event_id (idx), token_hash UNIQUE, token_sealed, state (idx),
title_sealed, description_sealed,
booker_name_sealed, booker_address_sealed, booker_address_fp (idx),
note_sealed, booker_zone,
hold_expires_at (idx), confirmed_at, cancelled_at, cancelled_by,
ip_fp (idx, nullable), ip_forget_at (idx), reschedules,
created_at, updated_at
  • Token (B14): 32 bytes from crypto/rand, hex.EncodeToString (64 characters, URL-safe as is). token_hash = HashFeedToken(token) is the lookup; token_sealed is the token under PurposeBookingToken, opened only to render a mail. A UNIQUE collision on insert regenerates once and then errors.
  • state: held → confirmed → cancelled, or held → expired.
  • title_sealed, description_sealed: the page's text at hold time (B20). Every public surface reads these, never the page's.
  • booker_zone: one of CommonZones or the host's own zone.
  • cancelled_by: '', booker, host.
  • ip_fp: Fingerprint(PurposeBookingIP, ip); ip_forget_at = created_at + 24h; nulled by the sweep at or after it; the earliest outstanding ip_forget_at is a NextDueAt source.
  • reschedules: capped at MaxReschedules = 5.

2.5 booking_mails

id, booking_id (idx), kind, recipient, revision,
due_at (idx), attempts, claimed_at, claim_id, sent_at, created_at
UNIQUE (booking_id, kind, recipient, revision)
  • kind ∈ {hold, confirmed, rescheduled, cancelled}; recipient ∈ {booker, host}; revision = the event's sequence when the row was made.
  • Rows are created inside the transition's transaction with due_at = now. For booking mail due_at means ONLY "eligible to claim from"; it carries no other meaning, which is why a claim may advance it (contrast reminders, §6). The handler that made them tries once, immediately (§6); the drain owns retries.
  • Claim (B13, B22): UPDATE … SET claimed_at = now, claim_id = <random>, due_at = now + claimLease WHERE id = ? AND sent_at IS NULL AND due_at <= now; the claimer keeps claim_id. Advancing due_at on claim is what makes due_at the ONE claimable instant: a crash after a claim leaves a row that becomes due again when the lease ends, and NextDueAt reads nothing but due_at (r4 A6). Acknowledge: SET sent_at = now WHERE id = ? AND claim_id = ?. Fail (a real send error): SET attempts = attempts + 1 WHERE id = ? AND claim_id = ? — due_at already points at the lease end. Defer (ErrMailBusy, no SMTP call was made): SET due_at = now + 30s, claimed_at = NULL WHERE id = ? AND claim_id = ?, attempts untouched (r4 B4). At MaxReminderAttempts, sent_at = -1 (gave up) and a log line says stage=booking.mail code=gave_up.
  • Supersession (B19): when a transition makes a booking cancelled or expired, every unsent row for that booking is deleted in the same transaction and the cancelled rows are queued (none for expired). When a reschedule succeeds, unsent rescheduled rows are deleted before the new ones are queued. A confirmed row survives a reschedule and its body says the current time. A row that has already been claimed and handed to the sender cannot be recalled; a booker may therefore receive "confirmed" a minute before "cancelled" if both raced. Stated, not hidden.
  • No content is stored. Bodies render at send time from the booking (snapshotted text, sealed booker fields, opened token), the event's allowlisted columns (§5.5) and the booking's CURRENT state.

2.6 Ownership

Every settings read or write of a page is WHERE id = ? AND person_id = ? → ErrNotFound → 404. PublicBookingPage(handle, code) is the only unscoped reader and requires published_at IS NOT NULL.

Terms. A booking's times and duration are the event's; a page edit never touches an existing event; a reschedule keeps the event's duration. A booking's title and description are its own snapshot (B20). The page's live text affects only future bookings.

2.7 The client IP

The instance listens on a unix socket only the platform edge reaches; the edge calls httputil.ProxyRequest.SetXForwarded, which REPLACES X-Forwarded-For with the dialling client (platform/internal/edge/ homemap.go setForwarded). The client IP is the LAST element of X-Forwarded-For; absent the header, the host of RemoteAddr. net.ParseIP(...).String(); unparseable → "unknown". Never logged.

---

3. The availability text

3.1 What it reads

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; a first clause with none is a problem.

mondays between 12–3, tuesdays 12–2, 5-7
mon-wed 9-5
weekdays 10am to 4pm
every day 8–6
saturday 10:30–13:00; sunday 14-16
  • Day part: a weekday in any weekdayAlt spelling, optionally plural, optionally every; a weekday range (mon-wed, monday to friday) walking forward modulo 7; weekdays; weekends; every day / daily / all days.
  • Time range: two clocks joined by -, –, —, to, until, till. A clock is H, H:MM, Ham, H:MMpm. Raw components are validated first, with no clamping: minute 0..59; with a meridiem the hour is 1..12; without, 0..24, and 24 only as 24:00 at the END. Meridiem: an explicit am/pm on either end applies to both unless the other says otherwise; with none, the working-day rule applies to the HOUR COMPONENT regardless of minutes: 1–7 → afternoon (5-7 → 17:00–19:00; 12:30-2:30 → 12:30–14:30), 8–12 and 0, 13–24 as written. End not after start → problem, the range quoted.
  • Filler dropped: between, from, and, on, at.

3.2 What it refuses

Every unconsumed token is a problem, quoted. All problems are reported. The page cannot be saved while any exists.

3.3 Where it lives

internal/calendar/avail.go + avail_describe.go (not avail_windows.go: Go reads a _windows.go suffix as a GOOS constraint and never compiles the file on Linux — found when the first build could not see it). ParseAvailability (text) (windows []Window, problems []string), pure, merged, sorted. Shares consumed, weekdayAlt, weekdays, rangeSep with quick add; does NOT call clock/applyMeridiem.

3.4 The pane

POST /settings/booking/parse → fragment: windows in words plus every problem. booking.js is a debounced fetch cloned from quickadd.js. Scriptless: a save with problems re-renders at 422 with the fragment.

---

4. Slots

ComputeSlots(rule, busy, now, from, to) is pure (slots.go); FreeSlots fetches busy and calls it (store_slots.go).

  1. Preconditions. ComputeSlots returns an error, never a list, when DurationMin < 1 or > 480, when Zone() fails, or when a window has EndMin <= StartMin (r3 f1). Callers surface the error; nothing loops.
  2. Bounds. earliest = now + max(notice_min, 10m); latest = now.In(hostLoc).AddDate(0,0,horizon_days). Offered iff start >= earliest && start >= from && start < to && end <= latest. to bounds the START. Every inequality is per candidate.
  3. Days. Host-zone calendar days from from to to, by AddDate(0,0,1) on a local midnight.
  4. Candidates. For each window that weekday, m from start_min by duration_min while m + duration_min <= end_min: start = time.Date(y, mo, d, 0, m, 0, 0, hostLoc), end = start + duration (elapsed). Kept only if all four hold (B17): start's wall minute is m; end's wall minute is m + duration_min on the same local date (or 1440); ToUTC(FormatLocal(start)) == start.Unix(); ToUTC(FormatLocal(end)) == end.Unix().
  5. Busy. Fetched once over [from - 1 day, to + 1 day) from EventsInRange(host) — after the two fixes in §4.1 — using OccStartUTC/OccEndUTC: keep Busy(kind) && status != cancelled; tentative is busy; the event being rescheduled is excluded by id; a tentative event whose booking is held with hold_expires_at <= now is NOT busy. All-day: the occurrence's dates are recovered as time.Unix(OccStartUTC).In(eventZoneOrUTC) through time.Unix(OccEndUTC-1).In(…), then each date's DayBounds in the HOST's zone. All-day notes are not busy.
  6. Buffer. Drop if [start - buffer, end + buffer) overlaps busy.
  7. Return ascending Slot{StartUTC, EndUTC, Local}; the page groups by the booker-zone date of START.

4.1 Two fixes to the range query it depends on

Both are calendar bugs, visible in every view, owned here because busy time must be what the calendar shows.

  • Moved override of a finished series (r2 B3). EventsInRange drops an override whose master the query did not select. Fix: override rows returned by rangeRows contribute their SeriesID to the set overridesFor loads, so pass 2 places them.
  • Long final occurrence (r3 f7, r4 A7). rangeRows excludes a master when until_utc < from, but UNTIL bounds the last occurrence's START, not its end, and an occurrence's elapsed length is not the master's across a DST day. Fix: the exclusion becomes until_utc + (end_utc - start_utc) + 90000 < from — the master's duration plus a 25-hour day of slack, which is conservative for any single transition. A day's extra expansion is cheap; a lost occurrence is not.
  • Multi-day span across spring DST (r4 B2). daysBetween divides elapsed hours by 24, so a 28 March 09:00 → 30 March 09:00 event in Dublin (47 h) spans "one day" and every later occurrence ends a day early. Fix: the day span is the difference of CIVIL dates (time.Date(y,m,d,0,0,0,0,time.UTC) for each end, then /24h), independent of the zone's offsets.
  • Recurrence drift across the spring gap (r7 f1). advance steps from each occurrence's RESOLVED time with AddDate, so a daily 01:30 meeting whose 29 March instance resolves to 02:30 (the gap) starts at 02:30 on every day after — the record says 01:30 and the calendar shows 02:30 forever. Fix: advancement carries the ORIGINAL civil clock (the master's hour and minute) onto each new civil date, time.Date(y, m, d, protoH, protoM, 0, 0, loc), independently of how the previous occurrence resolved; the gap day alone shows the resolved time. Regression: the daily 01:30 series shows 01:30 on 30 March and on a September day, and a September booking page does not offer 01:30–02:00 over it.
  • Fortnightly BYDAY skips a weekday (r8 f1). advance for a weekly rule with an interval searches only the next seven days and then jumps a whole interval, so FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE from Wednesday 2 September generates 16 September and never 14 September. Fix: weekly advancement enumerates every selected weekday within each ELIGIBLE civil week, anchored to the master's week (weeks counted from the master's week start, eligible when the difference is a multiple of the interval), in order. Regression: 14 and 16 September both show and neither is bookable.
  • A parsed rule the expander cannot execute (r8 f2). ParseRule accepts FREQ=MONTHLY;BYDAY=MO (every Monday of the month) and the expander ignores ByDay for monthly rules, falling back to the master's day of month. Fix: ParseRule validates COMBINATIONS — ByDay (non-ordinal) only with FREQ=WEEKLY; Ordinal/MonthDay only with FREQ=MONTHLY; nothing but INTERVAL/COUNT/UNTIL with DAILY and YEARLY — and returns ErrForeignRule otherwise. The rule is still stored verbatim (ruling 4), the calendar shows its first occurrence as it does for any foreign rule, and the strict reader fails closed on it. Regression: the import above → the day page shows one occurrence, EventsInRangeStrict errs, and the public page 503s.
  • Expansion that did not finish, or could not start (r4 B5, r6 f1; B23). Expand stops at 100,000 steps and returns an ordinary slice; and EventsInRange renders a master whose rule ParseRule rejects as its first occurrence only, never expanding it. Both are right for a calendar view and wrong for deciding free time. Expand gains a companion that also reports completeness, ExpandComplete (...) ([]Occurrence, bool); Expand calls it and drops the flag. EventsInRange gains a strict twin, EventsInRangeStrict, that returns ErrIncompleteExpansion when any BUSY master in the window (Busy(kind), not cancelled) was truncated OR has a rule ParseRule cannot read. Busy uses the strict twin and a public handler answers 503 with Retry-After: 60 when it errs; views keep the tolerant one, and the stored rule stays verbatim (ruling 4).

Regression tests in occurrence_test.go: a daily series ending in three days with its last occurrence moved ten days out shows on that day; a series whose final occurrence spans four days shows on its third; a weekly two-day Dublin event created 28 March 09:00 – 30 March 09:00 still occupies 30 March on its first AND a later occurrence; a daily rule with DTSTART in 1700 makes EventsInRangeStrict return ErrIncompleteExpansion for a 2026 window while EventsInRange still answers.

4.2 Cost

?day= outside [today, today + horizon_days] in the booker's zone →

  1. The day list is ONE EventsInRange over the horizon; a day page ONE over that day ±1. Every public route passes the gate in §7.2. A process-wide semaphore of 4 concurrent public slot computations guards FreeSlots from public handlers; when it is full the response is 503 with Retry-After: 5, and never a page computed from truncated busy data (r3 f8).

---

5. The public flow

Public routes sit beside /feed/{token}.ics: inside csrf.Protect, outside the session group, each wrapped by the gate (§7.2), which also sets Cache-Control: no-store and Referrer-Policy: no-referrer. The token is in the path and WILL be in the access log — the feed's own residual, stated.

5.1 GET /book/{handle}/{code}

PublicBookingPage → 404 byte-identical to the router's own. The page: title, description, duration, host's first name (opened NameSealed) or handle, a zone <select> of CommonZones plus the host's zone; ?zone= outside that set → the host's zone.

No ?day=: the next days with any free slot, as links carrying day (booker-zone date) and zone. With a valid day: that day's slots as <form method=post>s carrying hidden start (UTC unix) and zone. Pressing one posts WITHOUT name; the handler re-verifies the slot and renders the page with it fixed and the details form (name, address, note) beneath, start/zone hidden. Two POSTs, no script. booking.js may only preselect the zone.

5.2 POST /book/{handle}/{code} with name — hold

Body ≤ 16 KiB. name 1–60 runes AND ≤ 80 bytes; address a full reAddress match normalising to one address; note ≤ 1000 runes AND ≤ 4000 bytes. Over → 422.

Inside holdMu (B12): §7.1 caps → 429; FreeSlots for that day for the RESPONSE (so a taken slot can be reported without a write lock); start present → else 409 "That time has just gone". Then ONE transaction that (1) acquires the write lock first with a no-op write (UPDATE booking_pages SET updated_at = updated_at WHERE id = ?) — SQLite begins deferred, and a read inside a deferred transaction does not stop another writer committing under it (r7 f2); (2) re-runs the strict busy read and the slot check for that day THROUGH the transaction, and rolls back with ErrSlotTaken if the slot has gone; (3) inserts the tentative event (title "{page title} — {name}", notes = the note, kind meeting, tz = host zone, local strings from the instant), the booking (held, hold_expires_at = min(now + 30m, start), token hashed and sealed, title and description snapshotted, ip_fp, ip_forget_at), and booking_mails(hold, booker, 0).

Outside the mutex: try the row once (§6). Whatever happens, the hold stands (B16). Response 200 "Check your mail" with the address and the expiry in the booker's zone; if the try errored, plus "If nothing arrives in a few minutes, the hold lapses at {expiry} and you can try again."

5.3 GET /book/m/{token}

BookingByToken → 404. Reads the event ROW for status, deleted_at, times, sequence. Then:

  • held, now < hold_expires_at: what and when; ONE form → POST …/confirm, "Confirm this booking"; "Didn't ask for this? Do nothing; it expires at …". Nothing changes on GET.
  • held past expiry, or expired: "This hold expired at …".
  • confirmed, event not cancelled and not deleted: manage (§5.5).
  • otherwise: "cancelled", by whom (cancelled_by, or "the host" when the event is cancelled/deleted without a booking transition having recorded it — which §5.5 makes impossible, so this branch is defensive).

5.4 POST /book/m/{token}/confirm

One transaction: read events.start_utc; UPDATE bookings SET state='confirmed', confirmed_at WHERE id=? AND state='held' AND hold_expires_at > ? AND ? < start_utc; RowsAffected == 1 or stop and re-render §5.3. Then UPDATE events SET status='confirmed', sequence = sequence + 1; attendee row; queue (confirmed, host, seq). After commit: the host's default reminder (its own function; the booking is correct without it). Then try the row once. A second press affects zero rows and does nothing.

5.5 The manage page, the allowlist, and the host's authority

Manage shows: the snapshotted title and description, when (booker zone; host zone if different), who (host name/handle and address), and Add to calendar (…/calendar.ics), Reschedule, Cancel.

The public allowlist. Every public page, booker mail and the ICS may contain ONLY: the booking's snapshotted title and description, the event's start/end/status/sequence/deleted_at, the host's name/handle and address, the booker's own name and address, cancelled_by, the booking's state. Never the event's title, notes, location or attendee list. Public templates receive a publicBooking struct with exactly those fields; they never receive an event or a booking. A test edits notes, location and attendees as the host after confirmation and asserts none reaches manage, ICS or mail.

The host's authority (B18). The host edits the event through the ordinary form, and UpdateEvent/DeleteEvent enforce, inside their transactions, for any event a booking references:

  • Scheduling fields are read-only to the host. UpdateEvent compares the submitted start_local, end_local, tz, all_day, kind and rrule with the stored row and refuses if ANY differs: ValidationError{"start", "This is a booked meeting. Its time is changed by the person who booked it, or by cancelling and letting them rebook."} (one message; the field named is whichever differed first). When the event is booked, the form renders those controls DISABLED with that sentence beside them AND carries the stored values as hidden inputs — start, end, tz (the EVENT's own zone, not the host's profile zone), all_day, kind, repeat — so a scriptless title-only save submits exactly what is stored and passes; readEventForm takes tz from that hidden field when the event is booked and from p.Zone otherwise (B25). A stale form that still submits the old values passes because they match — and one that submits values from before a public reschedule fails because they do not (r4 A5). kind stays meeting forever (r4 B1).
  • status depends on the booking's state (r6 f3). Booking held: the event is tentative; the host may leave it tentative or set cancelled (→ booking expired); confirmed is refused ("Only the person who booked this can confirm it.") — promotion is the confirm transaction's alone, and the lapsed-hold exclusion in §4 depends on a held event staying tentative. Booking confirmed: confirmed or cancelled. Booking cancelled or expired: confirmed is refused ("This booking was cancelled. Make a new event instead."); tentative is never accepted from the form for a booked event. Any update to a soft-deleted event is refused.
  • status = cancelled on a confirmed booking → booking cancelled, cancelled_by = host, reminder queue rows deleted, unsent mail deleted, (cancelled, booker, seq) queued. On a held booking → expired, unsent mail deleted, nothing queued.
  • DeleteEvent → the same, plus sequence = sequence + 1 on the soft-deleted row; a held booking → expired.
  • sequence bumps are gorm.Expr("sequence + 1") everywhere.
  • The reminder claim path drops a queue row whose event is cancelled.

No revision field on the general event form is needed: the only fields a stale form could move are refused by the comparison above, and the rest (title, notes, location) the booker never sees.

5.6 Reschedule and cancel

GET …/reschedule: the §5.1 picker with the booking's event excluded from busy, computed with the EVENT's duration and the HOST's current zone, and a hidden rev = events.sequence. POST …/reschedule with start, zone, rev. Inside holdMu: state confirmed; reschedules < 5 (else 429); FreeSlots check for the response; one transaction that first takes the write lock with the same no-op write, re-runs the strict busy read and slot check through the transaction (B12), then UPDATE events SET start_local, end_local, tz, start_utc, end_utc, sequence = sequence + 1 WHERE id = ? AND sequence = ? (rev) — all five time columns together, tz = the zone the slot was computed in (B21) — zero rows → re-render; reschedules = reschedules + 1; unsent rescheduled rows deleted; (rescheduled, booker + host, seq+1) queued; and for every Reminder RULE on the event — not every queue row, because a fired reminder has none — a queue row is upserted from the new start in the same transaction (a tx form of rescheduleAfter keyed by reminder_id, which the unique index already enforces one-per-rule; it recomputes due_at, clears claim_id, claimed_at, retry_at, and resets fired_at) (B24, B26). Try both mails after commit.

POST …/cancel: UPDATE bookings SET state='cancelled', cancelled_at, cancelled_by='booker' WHERE id=? AND state='confirmed'; event status='cancelled', sequence = sequence + 1; reminder queue rows deleted; ALL unsent mail rows deleted; (cancelled, both, seq) queued. A second press affects zero rows.

5.7 Transitions

| From | Trigger | To | Guard (in the UPDATE) | Same transaction | |---|---|---|---|---| | — | hold | held | caps; slot free; holdMu | tentative event; snapshot; mail(hold, booker, 0) | | held | confirm POST | confirmed | hold_expires_at > now AND now < events.start_utc | event confirmed, seq+1; attendee; mail(confirmed, host, seq) | | held | sweep | expired | hold_expires_at <= now | event soft-deleted; reminder queue rows deleted; unsent mail deleted | | held | host cancels/deletes event | expired | — | unsent mail deleted | | confirmed | reschedule | confirmed | slot free; reschedules < 5; holdMu; events.sequence = rev | five time columns + seq+1; reschedules+1; unsent rescheduled deleted; mail(rescheduled, both, seq) | | confirmed | booker cancels | cancelled | state='confirmed' | event cancelled, seq+1; queue rows deleted; all unsent mail deleted; cancelled_by=booker; mail(cancelled, both, seq) | | confirmed | host cancels/deletes event | cancelled | state='confirmed' | seq+1; queue rows deleted; all unsent mail deleted; cancelled_by=host; mail(cancelled, booker, seq) | | any | sweep at ip_forget_at | same | ip_fp IS NOT NULL | ip_fp = NULL |

Every transition is a conditional UPDATE checking RowsAffected == 1; zero rows → re-read and render, never retry. No mail is sent inside a transaction; rows are.

5.8 The ICS (WriteBookingICS)

A dedicated writer in booking_ics.go taking the publicBooking struct and the event's opened UID. WriteICS is untouched and still says METHOD:PUBLISH for calendar exports; it is never called here.

BEGIN:VCALENDAR / VERSION:2.0 / PRODID / CALSCALE:GREGORIAN
METHOD:REQUEST            -- state confirmed, event not cancelled/deleted
METHOD:CANCEL             -- otherwise
BEGIN:VEVENT
UID:{event UID}           -- stable across reschedules
DTSTAMP:{event updated_at}
SEQUENCE:{event sequence}
DTSTART;TZID={event tz}:{start_local}   DTEND;TZID=…:{end_local}
SUMMARY:{snapshot title}   DESCRIPTION:{snapshot description, if any}
ORGANIZER:mailto:{host address}
ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:{booker}
STATUS:CONFIRMED | CANCELLED   -- effective state, not events.status alone
END:VEVENT / END:VCALENDAR

Served at GET /book/m/{token}/calendar.ics for confirmed and cancelled bookings (text/calendar; charset=utf-8, Content-Disposition: attachment; filename="booking.ics"); held and expired → 404. The event row is read including soft-deleted ones, so a host-deleted booking still exports its CANCEL. Folding, TEXT escaping and date formatting reuse ics_export.go's helpers. No CN= parameters: a parameter value has its own quoting rules (RFC 5545 §3.2, RFC 6868) that escapeText does not implement, and a name is not needed for the invitation to work — SUMMARY names the meeting and the addresses name the people (r4 B6). No URL:, no LOCATION, no other attendee.

---

6. Mail

Plain text through mail.Sender; one configured sender; the host's address is in the BODY and as ORGANIZER. Bodies are text/template files in templates/mail/, rendered at send time from allowlisted fields and the CURRENT state (B19). No booker content in any log line.

| kind | to | Says | |---|---|---| | hold | booker | "Somebody — we hope you — asked to book {title} with {host} on {when}. If that was you, confirm it here: {link}. If not, do nothing; it expires at {expiry}." | | confirmed | host | "{name} ({address}) booked {title}, {when}. Note: {note}. It is on your calendar: {event link}." If the booking is no longer confirmed when this renders, the body says so in its first line. | | rescheduled | booker, host | The current time in the recipient's zone; the manage link (booker) or event link (host). "If you added this to a calendar, download it again — the file does not update itself." | | cancelled | booker, host | What, when it had been, cancelled_by in words. |

Sending. All mail — reminders included — goes through ONE wrapper around the configured sender, boundedSender:

  • At most 4 sends in flight process-wide (a semaphore). A fifth caller gets ErrMailBusy immediately and its row is retried later.
  • Each attempt runs Send in a goroutine and waits at most min(20s, remaining budget), where the budget is the caller's context deadline. Past that it returns ErrMailTimeout; the goroutine is abandoned and releases its semaphore slot when the underlying send eventually returns or the OS closes the connection.
  • Honest limit (r3 f3): rastrillo's mail.SMTP ignores its context and sets no dial or protocol deadline, so an abandoned send can hold a goroutine and a socket until the kernel gives up. The semaphore bounds how many. The fix belongs upstream — a mail.SMTP that honours ctx — and is recorded in the calendar spec's debt list in the same commit. This spec does not claim to bound what it cannot.

Success → acknowledge with the claim id. ErrMailBusy → DEFER with the claim id: due again in 30 seconds, attempts untouched, because no SMTP call was made and a full wrapper is not a failed address (r4 B4). Any other error → fail with the claim id (attempts + 1; due_at is already the lease end). Nothing is concluded from an error except "try again later" (B16).

Reminders get the same three outcomes and the same identity (B22). reminder_queues gains two nullable columns by an additive migration: claim_id (random, set on claim, cleared by any reschedule of the row) and retry_at. due_at keeps its meaning — occurrence start minus offset — and is never written by a retry (r5 f3). Claim eligibility is fired_at IS NULL AND due_at <= now AND (retry_at IS NULL OR retry_at <= now) AND (claimed_at IS NULL OR claimed_at <= now - claimLease) (<= at both boundaries — r5 f5). The claim is one transaction: the conditional UPDATE, then a SELECT of the same row by id, and the reminder's payload — the occurrence start it announces, the offset, the event — is built from THAT row, never from the candidate row the worker read before claiming (r6 f2): a reschedule between the candidate read and the claim rewrites the row, the claim still succeeds, and a payload built from the stale candidate would announce the old time with a valid claim in hand. The same one transaction rule applies to ClaimBookingMail. MarkReminderSent, FailReminder (attempt + 1, claimed_at refreshed), the give-up path, the ErrMailBusy deferral (retry_at = now + 30s, claimed_at = NULL, attempts untouched) and RescheduleAfterFiring are each WHERE id = ? AND claim_id = ? and each checks RowsAffected; the mark and the advance happen in ONE transaction so a worker that loses its claim between them does neither. Zero rows → the worker stops; its message was sent and described the old time (§10).

---

7. Abuse

7.1 Durable caps, from bookings rows, inside holdMu, before 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 | 20 | | Attempts | booker_address_fp | created_at > now - 24h | 6 | | Reschedules | booking | lifetime | 5 |

Over → 429 "Too many bookings in progress. Try again later."

7.2 The gate (r2 B7, r3 f8)

An in-process token bucket per ip_fp on every public route: 60 requests/minute, 429 beyond. Bounded: at most 10,000 keys; a key idle for five minutes is evicted on the next insert; when full with no idle key, a NEW key is refused with 429 and Retry-After: 60 (r4 B7). Memory only, resets on restart. The gate also sets the two public headers. It is the cheap wall in front of the semaphore (§4.2) and the durable caps (§7.1).

7.3 What this does not stop

A patient adversary with many mailboxes and addresses books real slots one at a time. The host cancels them and, if it continues, unpublishes.

7.4 The oracle, stated as new

A published page lets anyone ask when this host is free, and every granted calendar shapes the answer. Publishing is deliberate: the button reads Publish and the sentence above it reads "Anyone with this link can see when you are free — not what you are doing, but when — including time blocked by team calendars you can see." AGENTS.md's leak list gains this as item 5.

---

8. The sweep

drain (the tick) runs, in order, inside the 45-second drainDeadline shared by everything, cheap work first, delivery last (r3 f3):

  1. Expire holds: held AND hold_expires_at <= now → expired, event soft-deleted, queue rows deleted, unsent mail deleted. ≤ 200.
  2. Forget IPs: ip_fp IS NOT NULL AND ip_forget_at <= now → NULL. ≤ 500.
  3. Reminders, as today but through boundedSender, until deadline/2 or the queue is empty.
  4. Booking mail: claim rows sent_at IS NULL AND due_at <= now (lease-aware), oldest first, until the deadline, each attempt bounded by the remaining budget.

DrainOnRequest (a signed-in page view) does ONE thing: an expiry if any is due, else a due reminder, else one booking mail. Never more, and never with more than a 5-second budget.

NextDueAt = the earliest CLAIMABLE instant of: reminders — max(due_at, coalesce(retry_at, 0), coalesce(claimed_at + claimLease, 0)) per unfired row (the existing mismatch, fixed here — r4 A6); next held hold_expires_at; next booking_mails.due_at with sent_at IS NULL (claims advance it, so it is always claimable); next ip_forget_at with ip_fp IS NOT NULL.

---

9. Testing

  • Parser: every §3.1 example; inheritance; first clause without a day; midnight; 25-26, 9:60, 13pm, 24:00 start vs end, 12:30-2:30; each separator; the en dash; three ranges; two problems both reported; merged and touching windows.
  • Slots: 12–3 / 60 with and without buffer; busy ending 5 min before from still removes the first slot when buffered; tentative blocks; lapsed unswept hold does not; cancelled does not; a note does not; a recurring all-day commitment blocks the OCCURRENCE date; an empty-zone all-day event blocks the host's whole day in Asia/Tokyo; notice floor 10 min; horizon bounds end; to bounds start; spring-forward and fold Sundays with 00:00–04:00: no offered slot straddles the transition, both ends round-trip, every slot is exactly its duration in elapsed seconds, pinned unix numbers; DurationMin: 0 and an inverted window return errors, not lists.
  • Range query: the moved-override-of-finished-series case; the long-final-occurrence case; a daily 01:30 Dublin series created 28 March shows 01:30 on 30 March and on a September day (gap drift); a fortnightly MO,WE series from Wednesday 2 September shows 14 AND 16 September; FREQ=MONTHLY;BYDAY=MO is foreign, shows once, and fails the strict reader; the final occurrence across the fold with UNTIL the night before (the 25-hour slack); the 47-hour two-day weekly event keeps its second day on later occurrences; a 1700 daily rule → ErrIncompleteExpansion from the strict reader and a 503 from the public page, while /month still renders; an imported busy series with a BYSETPOS rule → the same, and the rule is still stored verbatim.
  • Concurrency: 20 goroutines, one slot, one winner, -race; a host CreateEvent committed between a hold's response-time slot check and its transaction (inject via a test hook that runs a callback after the check) → the hold fails with ErrSlotTaken, no tentative event exists; the same for reschedule.
  • Flow: hold → one mail row, one send; GET link changes nothing; confirm POST confirms, attendee, default reminder, one host mail; second POST nothing; confirm after start refused; 409 on a taken slot; wrong token 404 byte-identical; expired says expired; ICS METHOD:REQUEST, ORGANIZER, ATTENDEE …ACCEPTED, SEQUENCE = event's, no URL:, parses with ParseICS; ICS for held → 404; reschedule with the right rev bumps sequence, writes tz, mails both; the SAME form resubmitted affects nothing; cancel → METHOD:CANCEL, unsent rows gone; host adds note/location/attendee → none reaches manage, ICS or mail; host cancels via edit → booker mailed, page says cancelled by host; host deletes → same, CANCEL ICS with higher sequence; host edits refused: any change to start, end, tz, all_day, kind or rrule (each tested), reviving a cancelled booking, editing a deleted event; a scriptless host edit that changes only the title succeeds, and still succeeds after the host changes their profile zone (the hidden tz is the event's); a stale host form submitted after a public reschedule is refused; the event form shows the scheduling controls disabled AND carries hidden inputs for each, for a booked event; page title changed after hold → manage and ICS still show the snapshot.
  • Zone change: host moves Dublin → Tokyo; reschedule writes tz=Asia/Tokyo and all four time columns agree (ToUTC(start_local, tz) == start_utc).
  • Mail queue: token recoverable after a simulated restart (insert row, tick, link works); a refused host mail is retried at due_at and gives up at MaxReminderAttempts; a stale claim_id cannot acknowledge or fail a row another claim holds; two drains do not send one row twice; cancel deletes every unsent row; NextDueAt reflects due_at not the lease; boundedSender returns ErrMailTimeout within budget against a stalling sender and ErrMailBusy when four are in flight; a row deferred by ErrMailBusy keeps attempts = 0 and sends once capacity returns; a crash after a claim (simulated: claim, do not ack) leaves NextDueAt at the lease end, not now.
  • Caps, gate, semaphore: sixth open hold per IP → 429; 21st attempt in 24h → 429 with none open; MaxReschedules; 61st public GET in a minute → 429; the gate holds ≤ 10,000 keys after 20,000 distinct IPs; ?day= past the horizon → 404; five concurrent public slot computations → one 503 with Retry-After.
  • Sweep: tick expires, soft-deletes, frees; retries owed mail; nulls ip_fp at ip_forget_at; reminders still progress with 30 mail rows queued behind a slow sender; DrainOnRequest does one thing; NextDueAt returns each source when earliest.
  • Zone(): 100 goroutines × garbage under -race; no failures cached.
  • Reminders: a cancelled event's queue row is dropped by the claim path; a worker that claimed a row which was then rescheduled AND re-claimed by another worker in the same second cannot mark, fail, defer or advance it (claim_id, not claimed_at); a reschedule followed by a simulated crash (no post-commit code) leaves the queue row already pointing at the new time; a reminder that FIRED (queue row deleted) before the booker moved the meeting to tomorrow gets a new queue row for tomorrow; a reschedule that lands between a worker's candidate read and its claim makes the worker announce the NEW time (the payload comes from the claimed row); a title-only host save of a HELD event succeeds and leaves it tentative; a host attempt to set a held event confirmed is refused; an ErrMailBusy deferral leaves due_at untouched and the reconstructed meeting time correct; a claim exactly claimLease old is claimable; NextDueAt for a claimed reminder is the lease end.
  • ICS: a booker named Doe, Jane "JJ": Support produces a file the app's parser AND a line-by-line check accept, with the address unchanged and no CN=.
  • The one rule: booker name, address, note, the token, the snapshotted title and description, the page's availability — none in the raw bytes.
  • Logs: no address, no token, after a failed send.
  • Browser: JS off — book, "check your mail", open the link, press Confirm, see Reschedule. JS on — the settings pane fills in.

---

10. What is NOT in this version

  • Group / round-robin pages; booker custom fields; payments; video links; reminders to the booker.
  • Host-initiated reschedule. A booked event's time belongs to the booking flow (B18); the host's form cannot move it, and says so beside the disabled controls. To move a booked meeting the host cancels and lets the booker rebook. Cancel and delete DO tell the booker.
  • Automatic calendar sync. The ICS is a download; the change mail says to download it again.
  • A revision field on the general event form. Booked events are protected by the field lock instead (§5.5).
  • A context-honouring SMTP sender. Upstream (§6).
  • Recalling a reminder mid-send. A reminder that was already handed to the sender when the booker rescheduled describes the old time; the reschedule mail that follows describes the new one.
  • Locale; retention (rows stay, sealed, minus ip_fp).

---

11. Changes to AGENTS.md and the calendar spec in the same commit

  • Settled decisions: rows B1, B2, B4, B5, B8, B11–B21.
  • Frontend: "Except a BOOKER, who typed their address to be mailed. Booking mail is the one outbound to a non-team address."
  • Data: "Booker name, address, note and TOKEN are sealed; the address has the keyed fingerprint. A page's public code is generated, never a chosen slug. Windows are plaintext: shape-of-time."
  • Data: "Every booking transition is a conditional UPDATE naming the state and revision it expects, checking RowsAffected. Zero rows is 'someone moved it first', never a retry."
  • Data: "A booked event's start, end, tz, all_day, kind and rrule belong to the booking flow. A host edit that changes any of them is refused; the form shows them disabled."
  • Recurrence: "Expand may stop early. Anything that decides FREE time reads EventsInRangeStrict and fails closed on ErrIncompleteExpansion."
  • Recurrence: "A day span is a difference of CIVIL dates, never elapsed hours over 24."
  • Reminders → Ticks: "NextDue is the earliest CLAIMABLE instant across reminders, hold expiries, owed booking mail and IP-forgets. New scheduled work goes here, never in a goroutine. Cheap work runs before delivery; reminders get the first half of the delivery budget."
  • Reminders: "No sender error is definite. Never free a resource because a send 'failed'. Every send goes through boundedSender. ErrMailBusy is a deferral, not an attempt."
  • Reminders: "Every queue row has a random claim_id; every completion mutation is conditional on it and checks RowsAffected, and a payload is built from the row the claim returned, never from the candidate read. A reminder's due_at is the occurrence minus the offset and is never written by a retry — retry_at is. Booking mail's due_at is retry eligibility only and has no retry_at."
  • Reminders: "A reschedule repairs the event's queue rows in the same transaction as the move."
  • Time: "Zone() takes bounded input on public routes: CommonZones plus the host's own zone."
  • Time: "A booking slot is the same length on the wall clock and the elapsed clock, or it is not offered."
  • The one rule's exclusions, item 5: the published-page oracle.
  • 2026-09-02-calendar-design.md §7 debt list: "rastrillo mail.SMTP ignores its context; an abandoned send holds a goroutine and a socket until the kernel closes it. Bounded to four in flight here; fixed properly upstream."

---

12. Review

Reviewer: GPT-6 via codex-cli 0.153.0 (identified itself as gpt-6-astra in round 2; rounds 1 and 3 could not expose the runtime identifier). 2026-09-07.

Round 1: REWRITE, 21 findings, all accepted. Round 2: REWRITE, 7 new + 11 reopened, all accepted. Both tables are in the git history of this file (9a28d7f, 0999c0d, 13b43a4).

Round 3: REWRITE. 11 new findings; of round 2, A11, A21, B2, B7 PARTIAL and B4 DOES NOT HOLD. Dispositions:

| Ref | Finding | Disposition | |---|---|---| | 1 | Zero-length host edit loops slot generation inside the mutex | Accepted — B18. Invariants on booked events; ComputeSlots refuses bad input (§4.0, §5.5). | | 2 | Reschedule after a host zone change writes a contradictory record | Accepted — B21. tz written with the four time columns (§5.6). | | 3 | The timed sender bounds waiting, not sending; reminders unbounded; 20 s ignores the shared deadline | Accepted as far as this repo can, scoped where it cannot. boundedSender wraps ALL mail, caps in-flight at 4, waits min(20s, remaining budget); cheap sweep work runs before delivery. The socket-level bound needs rastrillo's mail.SMTP to honour its context; recorded as upstream debt (§6, §11). | | 4 | Queue revisions do not identify content; a late "confirmed" after a cancel | Accepted — B19. Mail says the current state; cancel/expire delete every unsent row; a confirmed body opens by saying the booking is no longer confirmed if so; already-sent rows cannot be recalled and the spec says it. | | 5 | Ordinary host edits contradict terminal booking states | Accepted — B18. Reviving refused; deleted events refused; no general-form revision, stated in §10. | | 6 | NextDueAt advertises leased work; claims have no identity | Accepted — B13. due_at carries the retry time; claim_id on claim, ack and fail (§2.5, §8). | | 7 | UNTIL exclusion misses a long final occurrence | Accepted, a second calendar bug. until_utc + duration < from (§4.1). | | 8 | Unbounded bucket; unbounded work per request | Accepted. Bucket capped at 10,000 with idle eviction; a 4-wide semaphore on public slot computation, 503 when full (§4.2, §7.2). Expansion cost per request is the calendar's own and is not re-bounded here. | | 9 | ICS contract missing; held/expired undefined; effective status | Accepted. §5.8. | | 10 | Live page text changes the ICS without a new SEQUENCE | Accepted — B20. Title and description snapshotted onto the booking. | | 11 | Token generation unspecified | Accepted — B14. 32 bytes crypto/rand, hex, collision handling. |

Rejected: none. Scoped rather than solved: the socket-level send bound (finding 3, upstream) and the per-request cost of recurrence expansion (finding 8, the calendar's own).

Round 4: REWRITE. 7 new (6 blockers, 1 hardening); of round 3, A5, A6, A7 PARTIAL; 3 and 8 HOLD as scoped. Dispositions:

| Ref | Finding | Disposition | |---|---|---| | A5 | A stale host form re-saves old scheduling fields past every invariant | Accepted — B18 rewritten. Scheduling fields are read-only to the host; UpdateEvent refuses any difference (§5.5). | | A6 | A claim does not advance due_at; reminders have the same mismatch | Accepted — B22. Claim sets due_at = lease end; reminders' NextDueAt uses the claimable instant (§2.5, §8). | | A7 | Master duration is not a conservative bound across DST | Accepted. 25-hour slack on the UNTIL exclusion (§4.1). | | B1 | Kind → note makes a confirmed booking non-busy | Accepted — B18. kind is a locked field. | | B2 | daysBetween loses a day across spring DST | Accepted, a third calendar bug. Civil-date difference (§4.1). | | B3 | A reminder finishing mid-reschedule deletes the replacement | Accepted — B22. Mark and advance conditional on the claim (§6). | | B4 | ErrMailBusy burns attempts without reaching SMTP | Accepted — B22. Defer path, both queues (§2.5, §6). | | B5 | Expand truncation read as free time | Accepted — B23. ExpandComplete, EventsInRangeStrict, 503 fail-closed (§4.1). | | B6 | CN= cannot reuse TEXT escaping | Accepted by omission. No CN= (§5.8). | | B7 | Full bucket with no idle key undefined | Accepted. New key refused, 429 + Retry-After (§7.2). |

Rejected: none.

Round 5: REWRITE. 4 blockers, 2 hardening; of round 4, A6, B3, B4 PARTIAL. Dispositions:

| Ref | Finding | Disposition | |---|---|---| | A6/B3/f1 | claimed_at (whole seconds) is not a claim identity; failure and give-up paths unconditioned | Accepted — B22 rewritten. claim_id on reminder_queues; every mutation conditional; mark + advance atomic (§6). | | f2 | Reschedule commits before the reminder repair | Accepted — B24. Queue rows rebuilt inside the reschedule transaction (§5.6). | | B4/f3 | Writing a retry time into a reminder's due_at moves the meeting | Accepted — B22. retry_at column; due_at never written by a retry; NextDueAt over all three (§6, §8). | | f4 | Disabled controls submit nothing; reader's TZ = p.Zone | Accepted — B25. Hidden snapshot incl. the event's tz; reader takes it for booked events (§5.5). | | f5 | < vs <= at the lease boundary | Accepted. <= both sides (§6). | | f6 | §10 still described host rescheduling | Accepted. Rewritten (§10). |

Rejected: none.

Round 6: REWRITE. 3 blockers, 1 wording; round 5's six all HOLD.

| Ref | Finding | Disposition | |---|---|---| | f1 | An unparseable rule is not "truncated", so the strict reader let it through | Accepted — B23 widened. Strict fails on unparseable busy rules too (§4.1). | | f2 | A valid claim can carry a stale payload from the candidate read | Accepted — B22. Claim = conditional UPDATE + re-read in one transaction; payload from the re-read (§6). | | f3 | A held event has no permitted unchanged status | Accepted. Status rules keyed on booking state; host cannot promote a hold (§5.5). | | f4 | B22/§11 said retry_at for both queues | Accepted. Wording aligned (B22, §11). |

Rejected: none.

Round 7: REWRITE. 3 blockers, 0 polish; round 6's four HOLD.

| Ref | Finding | Disposition | |---|---|---| | f1 | Spring-gap resolution drifts every later occurrence | Accepted, a fourth calendar bug. Advancement carries the original civil clock (§4.1). | | f2 | Final busy check outside the write transaction; host CreateEvent slips in | Accepted — B12 widened. No-op write for the RESERVED lock, then the strict read, then the insert, one transaction (§5.2, §5.6). | | f3 | Rebuilding queue rows misses a fired reminder | Accepted — B26. Enumerate Reminder rules; upsert (§5.6). |

Rejected: none.

Round 8: REWRITE. 2 blockers, both recurrence-engine bugs; round 7's three HOLD.

| Ref | Finding | Disposition | |---|---|---| | f1 | Fortnightly BYDAY skips the first selected weekday of an eligible week | Accepted, a fifth calendar bug. Enumerate selected weekdays per eligible week (§4.1). | | f2 | ParseRule accepts combinations the expander misreads | Accepted, a sixth. Combination validation → ErrForeignRule; strict fails closed (§4.1). |

Rejected: none.

Spec review closed at revision 9. Eight rounds, 58 findings, 58 accepted (two scoped). The design has been stable since revision 5; rounds 6–8 moved transaction boundaries and found six bugs in the recurrence engine that every calendar view already had. Further review is of the branch's code, recorded in the calendar spec's review log.