| 1 | # Booking pages — design |
| 2 | |
| 3 | **Status:** specified 2026-09-07, before the code. Authored by Claude |
| 4 | Fable 5.1. Approved in outline by Paul in the session that wrote it |
| 5 | (three questions, answered: confirm by email link; everything the host |
| 6 | can see counts as busy; pages are configurable per person). **Revision |
| 7 | 9.** Reviewed eight times by GPT-6 (`gpt-6-astra` on the one run that |
| 8 | exposed its identifier) through `codex-cli 0.153.0`; all eight returned |
| 9 | REWRITE, each round narrower than the last. Rounds 7 and 8 found |
| 10 | nothing in the booking design itself: every finding was a pre-existing |
| 11 | bug in the recurrence engine the design depends on, and the spec owns |
| 12 | them (§4.1). The spec rounds end here; the next review is of the code. §12 records every finding |
| 13 | and disposition, including the two scoped rather than solved. |
| 14 | |
| 15 | Asked for: a Calendly / Cal.com style "book time with me" page to share |
| 16 | with people outside the team. The host sets availability in plain text |
| 17 | — "mondays between 12–3, tuesdays 12–2, 5-7" — and it just figures it |
| 18 | out. The booker gets a meeting invitation as ICS, and can reschedule or |
| 19 | cancel. |
| 20 | |
| 21 | This document is binding on the implementation. Where it disagrees with |
| 22 | AGENTS.md, AGENTS.md is wrong and is fixed in the same commit (§11). |
| 23 | |
| 24 | --- |
| 25 | |
| 26 | ## 0. The shape in one paragraph |
| 27 | |
| 28 | Each person owns any number of **booking pages**, made in Settings. |
| 29 | A page has a title, a duration, an availability sentence, and three |
| 30 | knobs with defaults. It is public at `/book/{handle}/{code}`, where the |
| 31 | code is generated. A stranger picks a slot in their own zone, types a |
| 32 | name and address, and the slot is **held** as a tentative event on the |
| 33 | host's personal calendar. One mail goes to the booker with one link. |
| 34 | The link shows a page with one button; pressing it **confirms**: the |
| 35 | event becomes confirmed, the booker becomes an attendee, the host is |
| 36 | told, and the same page is where the booker downloads the ICS, |
| 37 | reschedules, or cancels. A hold that is not confirmed expires — after |
| 38 | thirty minutes, or when the meeting would have started, whichever is |
| 39 | first — and is swept on the existing tick. |
| 40 | |
| 41 | --- |
| 42 | |
| 43 | ## 1. Rulings made here |
| 44 | |
| 45 | | # | Ruling | Rejected | Why | |
| 46 | |---|---|---|---| |
| 47 | | 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. | |
| 48 | | B2 | **Everything the host can see is busy** | Personal only | Nobody gets booked over the team standup. | |
| 49 | | B3 | **Pages are per person, any number** | One page | "15 min chat" and "60 min call" differ. | |
| 50 | | B4 | **Public identifier: chosen plaintext `handle` + GENERATED page code** | A chosen slug | A slug is prose joined to events (r1 f1). | |
| 51 | | 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. | |
| 52 | | 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). | |
| 53 | | B7 | **ONE availability parser, in Go** | JS parser | The standing rule. | |
| 54 | | B8 | **A held slot is a tentative EVENT** | A holds table | The range query already subtracts events. | |
| 55 | | 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. | |
| 56 | | B10 | **Unknown token → 404**, never 403 or "expired" | Distinguishing | No enumeration. | |
| 57 | | 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). | |
| 58 | | 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. | |
| 59 | | 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). | |
| 60 | | 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). | |
| 61 | | 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). | |
| 62 | | 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). | |
| 63 | | B16 | **No sender error is "definite."** Nothing is freed because a send failed | Marking holds failed | `smtp.SendMail` can fail after acceptance (r2 A12). | |
| 64 | | 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). | |
| 65 | | 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, all_day, 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. | |
| 66 | | 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. | |
| 67 | | 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). | |
| 68 | | 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). | |
| 69 | | B22 | **Both queues carry a random `claim_id`; every completion mutation — ack, fail, defer, give-up, advance — is conditional on the `claim_id` 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 `due_at` and retry through `retry_at`; booking mail's `due_at` is retry eligibility and nothing else** | Claim by ID or by `claimed_at`; retry time written into `due_at` | 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 `claimed_at` is not an identity (r5 f1); a reminder's `due_at` IS the occurrence minus the offset, so writing a retry time into it moved the meeting (r5 f3). | |
| 70 | | 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). | |
| 71 | | 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). | |
| 72 | | 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). | |
| 73 | |
| 74 | --- |
| 75 | |
| 76 | ## 2. Data |
| 77 | |
| 78 | All migrations additive, generated. |
| 79 | |
| 80 | ### 2.1 `people.handle` |
| 81 | |
| 82 | Nullable, unique, plaintext, `[a-z0-9-]{2,32}`, lowercased on save. |
| 83 | Reserved: `m`, `new`, `static`. Changing it changes every public URL |
| 84 | and the settings page says so. |
| 85 | |
| 86 | ### 2.2 `booking_pages` |
| 87 | |
| 88 | ``` |
| 89 | id, person_id (idx), code, title_sealed, description_sealed, |
| 90 | duration_min, buffer_min, notice_min, horizon_days, |
| 91 | availability_text_sealed, published_at (nullable), created_at, updated_at |
| 92 | UNIQUE (person_id, code) |
| 93 | ``` |
| 94 | |
| 95 | - `code`: eight of `[a-z0-9]`, generated, immutable, plaintext. Not a |
| 96 | secret — it is in a shared URL — and distinct from the management |
| 97 | token (§2.4). |
| 98 | - `title_sealed` ≤ **100 bytes**; `description_sealed` ≤ 1000 bytes. |
| 99 | Byte limits, because the event validator's are bytes (r2 B6). |
| 100 | - `duration_min` 5–480; `buffer_min` 0–120 (default 10); `notice_min` |
| 101 | 0–10080 (default 120) with a **floor of 10** at slot time (B15); |
| 102 | `horizon_days` 1–90 (default 30). |
| 103 | - `published_at`: NULL is unpublished → 404. Publishing is explicit |
| 104 | (§7.4). |
| 105 | |
| 106 | ### 2.3 `booking_windows` |
| 107 | |
| 108 | ``` |
| 109 | id, page_id (idx), weekday (0=Sunday..6), start_min, end_min |
| 110 | ``` |
| 111 | |
| 112 | Plaintext (shape-of-time). `0 <= start_min < end_min <= 1440`. |
| 113 | Rewritten wholesale on save, **merged** first. |
| 114 | |
| 115 | ### 2.4 `bookings` |
| 116 | |
| 117 | ``` |
| 118 | id, page_id (idx), event_id (idx), token_hash UNIQUE, token_sealed, state (idx), |
| 119 | title_sealed, description_sealed, |
| 120 | booker_name_sealed, booker_address_sealed, booker_address_fp (idx), |
| 121 | note_sealed, booker_zone, |
| 122 | hold_expires_at (idx), confirmed_at, cancelled_at, cancelled_by, |
| 123 | ip_fp (idx, nullable), ip_forget_at (idx), reschedules, |
| 124 | created_at, updated_at |
| 125 | ``` |
| 126 | |
| 127 | - **Token (B14):** 32 bytes from `crypto/rand`, `hex.EncodeToString` |
| 128 | (64 characters, URL-safe as is). `token_hash = HashFeedToken(token)` |
| 129 | is the lookup; `token_sealed` is the token under |
| 130 | `PurposeBookingToken`, opened only to render a mail. A `UNIQUE` |
| 131 | collision on insert regenerates once and then errors. |
| 132 | - `state`: `held` → `confirmed` → `cancelled`, or `held` → `expired`. |
| 133 | - `title_sealed`, `description_sealed`: **the page's text at hold time** |
| 134 | (B20). Every public surface reads these, never the page's. |
| 135 | - `booker_zone`: one of `CommonZones` or the host's own zone. |
| 136 | - `cancelled_by`: `''`, `booker`, `host`. |
| 137 | - `ip_fp`: `Fingerprint(PurposeBookingIP, ip)`; `ip_forget_at = |
| 138 | created_at + 24h`; nulled by the sweep at or after it; the earliest |
| 139 | outstanding `ip_forget_at` is a `NextDueAt` source. |
| 140 | - `reschedules`: capped at `MaxReschedules = 5`. |
| 141 | |
| 142 | ### 2.5 `booking_mails` |
| 143 | |
| 144 | ``` |
| 145 | id, booking_id (idx), kind, recipient, revision, |
| 146 | due_at (idx), attempts, claimed_at, claim_id, sent_at, created_at |
| 147 | UNIQUE (booking_id, kind, recipient, revision) |
| 148 | ``` |
| 149 | |
| 150 | - `kind` ∈ {`hold`, `confirmed`, `rescheduled`, `cancelled`}; |
| 151 | `recipient` ∈ {`booker`, `host`}; `revision` = the event's |
| 152 | `sequence` when the row was made. |
| 153 | - Rows are created inside the transition's transaction with `due_at = |
| 154 | now`. For booking mail `due_at` means ONLY "eligible to claim from"; |
| 155 | it carries no other meaning, which is why a claim may advance it |
| 156 | (contrast reminders, §6). The handler that made them tries once, |
| 157 | immediately (§6); the drain owns retries. |
| 158 | - **Claim (B13, B22):** `UPDATE … SET claimed_at = now, claim_id = |
| 159 | <random>, due_at = now + claimLease WHERE id = ? AND sent_at IS NULL |
| 160 | AND due_at <= now`; the claimer keeps `claim_id`. Advancing `due_at` |
| 161 | on claim is what makes `due_at` the ONE claimable instant: a crash |
| 162 | after a claim leaves a row that becomes due again when the lease |
| 163 | ends, and `NextDueAt` reads nothing but `due_at` (r4 A6). |
| 164 | **Acknowledge:** `SET sent_at = now WHERE id = ? AND claim_id = ?`. |
| 165 | **Fail (a real send error):** `SET attempts = attempts + 1 WHERE id = |
| 166 | ? AND claim_id = ?` — `due_at` already points at the lease end. |
| 167 | **Defer (`ErrMailBusy`, no SMTP call was made):** `SET due_at = now + |
| 168 | 30s, claimed_at = NULL WHERE id = ? AND claim_id = ?`, attempts |
| 169 | untouched (r4 B4). At `MaxReminderAttempts`, `sent_at = -1` (gave up) |
| 170 | and a log line says `stage=booking.mail code=gave_up`. |
| 171 | - **Supersession (B19):** when a transition makes a booking |
| 172 | `cancelled` or `expired`, every unsent row for that booking is |
| 173 | deleted in the same transaction and the `cancelled` rows are queued |
| 174 | (none for `expired`). When a reschedule succeeds, unsent |
| 175 | `rescheduled` rows are deleted before the new ones are queued. A |
| 176 | `confirmed` row survives a reschedule and its body says the current |
| 177 | time. A row that has already been claimed and handed to the sender |
| 178 | cannot be recalled; a booker may therefore receive "confirmed" a |
| 179 | minute before "cancelled" if both raced. Stated, not hidden. |
| 180 | - No content is stored. Bodies render at send time from the booking |
| 181 | (snapshotted text, sealed booker fields, opened token), the event's |
| 182 | allowlisted columns (§5.5) and the booking's CURRENT state. |
| 183 | |
| 184 | ### 2.6 Ownership |
| 185 | |
| 186 | Every settings read or write of a page is `WHERE id = ? AND person_id |
| 187 | = ?` → `ErrNotFound` → 404. `PublicBookingPage(handle, code)` is the |
| 188 | only unscoped reader and requires `published_at IS NOT NULL`. |
| 189 | |
| 190 | **Terms.** A booking's times and duration are the event's; a page edit |
| 191 | never touches an existing event; a reschedule keeps the event's |
| 192 | duration. A booking's title and description are its own snapshot |
| 193 | (B20). The page's live text affects only future bookings. |
| 194 | |
| 195 | ### 2.7 The client IP |
| 196 | |
| 197 | The instance listens on a unix socket only the platform edge reaches; |
| 198 | the edge calls `httputil.ProxyRequest.SetXForwarded`, which REPLACES |
| 199 | `X-Forwarded-For` with the dialling client (`platform/internal/edge/ |
| 200 | homemap.go setForwarded`). **The client IP is the LAST element of |
| 201 | `X-Forwarded-For`**; absent the header, the host of `RemoteAddr`. |
| 202 | `net.ParseIP(...).String()`; unparseable → `"unknown"`. Never logged. |
| 203 | |
| 204 | --- |
| 205 | |
| 206 | ## 3. The availability text |
| 207 | |
| 208 | ### 3.1 What it reads |
| 209 | |
| 210 | Clauses split on `,` and `;`. Each clause is a **day part** then one or |
| 211 | more **time ranges**; a clause with no day part inherits the previous |
| 212 | clause's days; a first clause with none is a problem. |
| 213 | |
| 214 | ``` |
| 215 | mondays between 12–3, tuesdays 12–2, 5-7 |
| 216 | mon-wed 9-5 |
| 217 | weekdays 10am to 4pm |
| 218 | every day 8–6 |
| 219 | saturday 10:30–13:00; sunday 14-16 |
| 220 | ``` |
| 221 | |
| 222 | - **Day part**: a weekday in any `weekdayAlt` spelling, optionally |
| 223 | plural, optionally `every`; a weekday range (`mon-wed`, `monday to |
| 224 | friday`) walking forward modulo 7; `weekdays`; `weekends`; `every |
| 225 | day` / `daily` / `all days`. |
| 226 | - **Time range**: two clocks joined by `-`, `–`, `—`, `to`, `until`, |
| 227 | `till`. A clock is `H`, `H:MM`, `Ham`, `H:MMpm`. **Raw components are |
| 228 | validated first, with no clamping**: minute `0..59`; with a meridiem |
| 229 | the hour is `1..12`; without, `0..24`, and `24` only as `24:00` at |
| 230 | the END. **Meridiem:** an explicit `am`/`pm` on either end applies to |
| 231 | both unless the other says otherwise; with none, the working-day rule |
| 232 | applies to the HOUR COMPONENT regardless of minutes: 1–7 → afternoon |
| 233 | (`5-7` → 17:00–19:00; `12:30-2:30` → 12:30–14:30), 8–12 and 0, 13–24 |
| 234 | as written. End not after start → problem, the range quoted. |
| 235 | - Filler dropped: `between`, `from`, `and`, `on`, `at`. |
| 236 | |
| 237 | ### 3.2 What it refuses |
| 238 | |
| 239 | Every unconsumed token is a problem, quoted. All problems are |
| 240 | reported. The page cannot be saved while any exists. |
| 241 | |
| 242 | ### 3.3 Where it lives |
| 243 | |
| 244 | `internal/calendar/avail.go` + `avail_describe.go` (not `avail_windows.go`: |
| 245 | Go reads a `_windows.go` suffix as a GOOS constraint and never compiles |
| 246 | the file on Linux — found when the first build could not see it). `ParseAvailability |
| 247 | (text) (windows []Window, problems []string)`, pure, merged, sorted. |
| 248 | Shares `consumed`, `weekdayAlt`, `weekdays`, `rangeSep` with quick add; |
| 249 | does NOT call `clock`/`applyMeridiem`. |
| 250 | |
| 251 | ### 3.4 The pane |
| 252 | |
| 253 | `POST /settings/booking/parse` → fragment: windows in words plus every |
| 254 | problem. `booking.js` is a debounced fetch cloned from `quickadd.js`. |
| 255 | Scriptless: a save with problems re-renders at 422 with the fragment. |
| 256 | |
| 257 | --- |
| 258 | |
| 259 | ## 4. Slots |
| 260 | |
| 261 | `ComputeSlots(rule, busy, now, from, to)` is pure (`slots.go`); |
| 262 | `FreeSlots` fetches busy and calls it (`store_slots.go`). |
| 263 | |
| 264 | 0. **Preconditions.** `ComputeSlots` returns an error, never a list, |
| 265 | when `DurationMin < 1` or `> 480`, when `Zone()` fails, or when a |
| 266 | window has `EndMin <= StartMin` (r3 f1). Callers surface the error; |
| 267 | nothing loops. |
| 268 | 1. **Bounds.** `earliest = now + max(notice_min, 10m)`; `latest = |
| 269 | now.In(hostLoc).AddDate(0,0,horizon_days)`. Offered iff `start >= |
| 270 | earliest && start >= from && start < to && end <= latest`. `to` |
| 271 | bounds the START. Every inequality is per candidate. |
| 272 | 2. **Days.** Host-zone calendar days from `from` to `to`, by |
| 273 | `AddDate(0,0,1)` on a local midnight. |
| 274 | 3. **Candidates.** For each window that weekday, `m` from `start_min` |
| 275 | by `duration_min` while `m + duration_min <= end_min`: `start = |
| 276 | time.Date(y, mo, d, 0, m, 0, 0, hostLoc)`, `end = start + duration` |
| 277 | (elapsed). Kept only if all four hold (B17): `start`'s wall minute |
| 278 | is `m`; `end`'s wall minute is `m + duration_min` on the same local |
| 279 | date (or `1440`); `ToUTC(FormatLocal(start)) == start.Unix()`; |
| 280 | `ToUTC(FormatLocal(end)) == end.Unix()`. |
| 281 | 4. **Busy.** Fetched once over `[from - 1 day, to + 1 day)` from |
| 282 | `EventsInRange(host)` — **after the two fixes in §4.1** — using |
| 283 | `OccStartUTC`/`OccEndUTC`: keep `Busy(kind) && status != cancelled`; |
| 284 | tentative is busy; the event being rescheduled is excluded by id; a |
| 285 | tentative event whose booking is `held` with `hold_expires_at <= |
| 286 | now` is NOT busy. **All-day**: the occurrence's dates are recovered |
| 287 | as `time.Unix(OccStartUTC).In(eventZoneOrUTC)` through |
| 288 | `time.Unix(OccEndUTC-1).In(…)`, then each date's `DayBounds` in the |
| 289 | HOST's zone. All-day notes are not busy. |
| 290 | 5. **Buffer.** Drop if `[start - buffer, end + buffer)` overlaps busy. |
| 291 | 6. Return ascending `Slot{StartUTC, EndUTC, Local}`; the page groups |
| 292 | by the booker-zone date of START. |
| 293 | |
| 294 | ### 4.1 Two fixes to the range query it depends on |
| 295 | |
| 296 | Both are calendar bugs, visible in every view, owned here because busy |
| 297 | time must be what the calendar shows. |
| 298 | |
| 299 | - **Moved override of a finished series (r2 B3).** `EventsInRange` |
| 300 | drops an override whose master the query did not select. Fix: |
| 301 | override rows returned by `rangeRows` contribute their `SeriesID` to |
| 302 | the set `overridesFor` loads, so pass 2 places them. |
| 303 | - **Long final occurrence (r3 f7, r4 A7).** `rangeRows` excludes a |
| 304 | master when `until_utc < from`, but `UNTIL` bounds the last |
| 305 | occurrence's START, not its end, and an occurrence's elapsed length |
| 306 | is not the master's across a DST day. Fix: the exclusion becomes |
| 307 | `until_utc + (end_utc - start_utc) + 90000 < from` — the master's |
| 308 | duration plus a 25-hour day of slack, which is conservative for any |
| 309 | single transition. A day's extra expansion is cheap; a lost |
| 310 | occurrence is not. |
| 311 | - **Multi-day span across spring DST (r4 B2).** `daysBetween` divides |
| 312 | elapsed hours by 24, so a 28 March 09:00 → 30 March 09:00 event in |
| 313 | Dublin (47 h) spans "one day" and every later occurrence ends a day |
| 314 | early. Fix: the day span is the difference of CIVIL dates |
| 315 | (`time.Date(y,m,d,0,0,0,0,time.UTC)` for each end, then `/24h`), |
| 316 | independent of the zone's offsets. |
| 317 | - **Recurrence drift across the spring gap (r7 f1).** `advance` steps |
| 318 | from each occurrence's RESOLVED time with `AddDate`, so a daily |
| 319 | 01:30 meeting whose 29 March instance resolves to 02:30 (the gap) |
| 320 | starts at 02:30 on every day after — the record says 01:30 and the |
| 321 | calendar shows 02:30 forever. Fix: advancement carries the ORIGINAL |
| 322 | civil clock (the master's hour and minute) onto each new civil date, |
| 323 | `time.Date(y, m, d, protoH, protoM, 0, 0, loc)`, independently of how |
| 324 | the previous occurrence resolved; the gap day alone shows the |
| 325 | resolved time. Regression: the daily 01:30 series shows 01:30 on 30 |
| 326 | March and on a September day, and a September booking page does not |
| 327 | offer 01:30–02:00 over it. |
| 328 | - **Fortnightly `BYDAY` skips a weekday (r8 f1).** `advance` for a |
| 329 | weekly rule with an interval searches only the next seven days and |
| 330 | then jumps a whole interval, so `FREQ=WEEKLY;INTERVAL=2;BYDAY=MO,WE` |
| 331 | from Wednesday 2 September generates 16 September and never 14 |
| 332 | September. Fix: weekly advancement enumerates every selected weekday |
| 333 | within each ELIGIBLE civil week, anchored to the master's week |
| 334 | (weeks counted from the master's week start, eligible when the |
| 335 | difference is a multiple of the interval), in order. Regression: 14 |
| 336 | and 16 September both show and neither is bookable. |
| 337 | - **A parsed rule the expander cannot execute (r8 f2).** `ParseRule` |
| 338 | accepts `FREQ=MONTHLY;BYDAY=MO` (every Monday of the month) and the |
| 339 | expander ignores `ByDay` for monthly rules, falling back to the |
| 340 | master's day of month. Fix: `ParseRule` validates COMBINATIONS — |
| 341 | `ByDay` (non-ordinal) only with `FREQ=WEEKLY`; `Ordinal`/`MonthDay` |
| 342 | only with `FREQ=MONTHLY`; nothing but `INTERVAL`/`COUNT`/`UNTIL` with |
| 343 | `DAILY` and `YEARLY` — and returns `ErrForeignRule` otherwise. The |
| 344 | rule is still stored verbatim (ruling 4), the calendar shows its |
| 345 | first occurrence as it does for any foreign rule, and the strict |
| 346 | reader fails closed on it. Regression: the import above → the day |
| 347 | page shows one occurrence, `EventsInRangeStrict` errs, and the public |
| 348 | page 503s. |
| 349 | - **Expansion that did not finish, or could not start (r4 B5, r6 f1; |
| 350 | B23).** `Expand` stops at 100,000 steps and returns an ordinary |
| 351 | slice; and `EventsInRange` renders a master whose rule `ParseRule` |
| 352 | rejects as its first occurrence only, never expanding it. Both are |
| 353 | right for a calendar view and wrong for deciding free time. `Expand` |
| 354 | gains a companion that also reports completeness, `ExpandComplete |
| 355 | (...) ([]Occurrence, bool)`; `Expand` calls it and drops the flag. |
| 356 | `EventsInRange` gains a strict twin, `EventsInRangeStrict`, that |
| 357 | returns `ErrIncompleteExpansion` when any BUSY master in the window |
| 358 | (`Busy(kind)`, not cancelled) was truncated OR has a rule `ParseRule` |
| 359 | cannot read. **Busy uses the strict twin** and a public handler |
| 360 | answers 503 with `Retry-After: 60` when it errs; views keep the |
| 361 | tolerant one, and the stored rule stays verbatim (ruling 4). |
| 362 | |
| 363 | Regression tests in `occurrence_test.go`: a daily series ending in |
| 364 | three days with its last occurrence moved ten days out shows on that |
| 365 | day; a series whose final occurrence spans four days shows on its |
| 366 | third; a weekly two-day Dublin event created 28 March 09:00 – 30 March |
| 367 | 09:00 still occupies 30 March on its first AND a later occurrence; a |
| 368 | daily rule with `DTSTART` in 1700 makes `EventsInRangeStrict` return |
| 369 | `ErrIncompleteExpansion` for a 2026 window while `EventsInRange` still |
| 370 | answers. |
| 371 | |
| 372 | ### 4.2 Cost |
| 373 | |
| 374 | `?day=` outside `[today, today + horizon_days]` in the booker's zone → |
| 375 | 404. The day list is ONE `EventsInRange` over the horizon; a day page |
| 376 | ONE over that day ±1. Every public route passes the gate in §7.2. A |
| 377 | process-wide semaphore of **4** concurrent public slot computations |
| 378 | guards `FreeSlots` from public handlers; when it is full the response |
| 379 | is **503** with `Retry-After: 5`, and never a page computed from |
| 380 | truncated busy data (r3 f8). |
| 381 | |
| 382 | --- |
| 383 | |
| 384 | ## 5. The public flow |
| 385 | |
| 386 | Public routes sit beside `/feed/{token}.ics`: inside `csrf.Protect`, |
| 387 | outside the session group, each wrapped by the gate (§7.2), which also |
| 388 | sets `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. The |
| 389 | token is in the path and WILL be in the access log — the feed's own |
| 390 | residual, stated. |
| 391 | |
| 392 | ### 5.1 `GET /book/{handle}/{code}` |
| 393 | |
| 394 | `PublicBookingPage` → 404 byte-identical to the router's own. The page: |
| 395 | title, description, duration, host's first name (opened `NameSealed`) |
| 396 | or handle, a zone `<select>` of `CommonZones` plus the host's zone; |
| 397 | `?zone=` outside that set → the host's zone. |
| 398 | |
| 399 | No `?day=`: the next days with any free slot, as links carrying `day` |
| 400 | (booker-zone date) and `zone`. With a valid `day`: that day's slots as |
| 401 | `<form method=post>`s carrying hidden `start` (UTC unix) and `zone`. |
| 402 | Pressing one posts WITHOUT `name`; the handler re-verifies the slot and |
| 403 | renders the page with it fixed and the details form (`name`, `address`, |
| 404 | `note`) beneath, `start`/`zone` hidden. Two POSTs, no script. |
| 405 | `booking.js` may only preselect the zone. |
| 406 | |
| 407 | ### 5.2 `POST /book/{handle}/{code}` with `name` — hold |
| 408 | |
| 409 | Body ≤ 16 KiB. `name` 1–60 runes AND ≤ 80 bytes; `address` a full |
| 410 | `reAddress` match normalising to one address; `note` ≤ 1000 runes AND |
| 411 | ≤ 4000 bytes. Over → 422. |
| 412 | |
| 413 | Inside `holdMu` (B12): §7.1 caps → 429; `FreeSlots` for that day for |
| 414 | the RESPONSE (so a taken slot can be reported without a write lock); |
| 415 | `start` present → else 409 "That time has just gone". Then ONE |
| 416 | transaction that (1) acquires the write lock first with a no-op write |
| 417 | (`UPDATE booking_pages SET updated_at = updated_at WHERE id = ?`) — |
| 418 | SQLite begins deferred, and a read inside a deferred transaction does |
| 419 | not stop another writer committing under it (r7 f2); (2) re-runs the |
| 420 | strict busy read and the slot check for that day THROUGH the |
| 421 | transaction, and rolls back with `ErrSlotTaken` if the slot has gone; |
| 422 | (3) inserts the tentative event (title `"{page title} — {name}"`, |
| 423 | notes = the note, kind meeting, `tz` = host zone, local strings from |
| 424 | the instant), the booking (`held`, `hold_expires_at = min(now + 30m, |
| 425 | start)`, token hashed and sealed, title and description snapshotted, |
| 426 | `ip_fp`, `ip_forget_at`), and `booking_mails(hold, booker, 0)`. |
| 427 | |
| 428 | Outside the mutex: try the row once (§6). **Whatever happens, the hold |
| 429 | stands** (B16). Response 200 "Check your mail" with the address and |
| 430 | the expiry in the booker's zone; if the try errored, plus "If nothing |
| 431 | arrives in a few minutes, the hold lapses at {expiry} and you can try |
| 432 | again." |
| 433 | |
| 434 | ### 5.3 `GET /book/m/{token}` |
| 435 | |
| 436 | `BookingByToken` → 404. Reads the event ROW for `status`, `deleted_at`, |
| 437 | times, `sequence`. Then: |
| 438 | |
| 439 | - `held`, `now < hold_expires_at`: what and when; ONE form → `POST |
| 440 | …/confirm`, "Confirm this booking"; "Didn't ask for this? Do |
| 441 | nothing; it expires at …". Nothing changes on GET. |
| 442 | - `held` past expiry, or `expired`: "This hold expired at …". |
| 443 | - `confirmed`, event not cancelled and not deleted: manage (§5.5). |
| 444 | - otherwise: "cancelled", by whom (`cancelled_by`, or "the host" when |
| 445 | the event is cancelled/deleted without a booking transition having |
| 446 | recorded it — which §5.5 makes impossible, so this branch is |
| 447 | defensive). |
| 448 | |
| 449 | ### 5.4 `POST /book/m/{token}/confirm` |
| 450 | |
| 451 | One transaction: read `events.start_utc`; `UPDATE bookings SET |
| 452 | state='confirmed', confirmed_at WHERE id=? AND state='held' AND |
| 453 | hold_expires_at > ? AND ? < start_utc`; `RowsAffected == 1` or stop and |
| 454 | re-render §5.3. Then `UPDATE events SET status='confirmed', sequence = |
| 455 | sequence + 1`; attendee row; queue `(confirmed, host, seq)`. After |
| 456 | commit: the host's default reminder (its own function; the booking is |
| 457 | correct without it). Then try the row once. A second press affects |
| 458 | zero rows and does nothing. |
| 459 | |
| 460 | ### 5.5 The manage page, the allowlist, and the host's authority |
| 461 | |
| 462 | Manage shows: the snapshotted title and description, when (booker |
| 463 | zone; host zone if different), who (host name/handle and address), and |
| 464 | **Add to calendar** (`…/calendar.ics`), **Reschedule**, **Cancel**. |
| 465 | |
| 466 | **The public allowlist.** Every public page, booker mail and the ICS |
| 467 | may contain ONLY: the booking's snapshotted title and description, the |
| 468 | event's `start`/`end`/`status`/`sequence`/`deleted_at`, the host's |
| 469 | name/handle and address, the booker's own name and address, |
| 470 | `cancelled_by`, the booking's state. **Never** the event's title, |
| 471 | notes, location or attendee list. Public templates receive a |
| 472 | `publicBooking` struct with exactly those fields; they never receive an |
| 473 | event or a booking. A test edits notes, location and attendees as the |
| 474 | host after confirmation and asserts none reaches manage, ICS or mail. |
| 475 | |
| 476 | **The host's authority (B18).** The host edits the event through the |
| 477 | ordinary form, and `UpdateEvent`/`DeleteEvent` enforce, inside their |
| 478 | transactions, for any event a booking references: |
| 479 | |
| 480 | - **Scheduling fields are read-only to the host.** `UpdateEvent` |
| 481 | compares the submitted `start_local`, `end_local`, `tz`, `all_day`, |
| 482 | `kind` and `rrule` with the stored row and refuses if ANY differs: |
| 483 | `ValidationError{"start", "This is a booked meeting. Its time is |
| 484 | changed by the person who booked it, or by cancelling and letting |
| 485 | them rebook."}` (one message; the field named is whichever differed |
| 486 | first). When the event is booked, the form renders those controls |
| 487 | DISABLED with that sentence beside them AND carries the stored |
| 488 | values as hidden inputs — `start`, `end`, `tz` (the EVENT's own zone, |
| 489 | not the host's profile zone), `all_day`, `kind`, `repeat` — so a |
| 490 | scriptless title-only save submits exactly what is stored and |
| 491 | passes; `readEventForm` takes `tz` from that hidden field when the |
| 492 | event is booked and from `p.Zone` otherwise (B25). A stale form that |
| 493 | still submits the old values passes because they match — and one |
| 494 | that submits values from before a public reschedule fails because |
| 495 | they do not (r4 A5). `kind` stays `meeting` forever (r4 B1). |
| 496 | - `status` depends on the booking's state (r6 f3). Booking `held`: |
| 497 | the event is `tentative`; the host may leave it `tentative` or set |
| 498 | `cancelled` (→ booking `expired`); `confirmed` is refused ("Only the |
| 499 | person who booked this can confirm it.") — promotion is the confirm |
| 500 | transaction's alone, and the lapsed-hold exclusion in §4 depends on |
| 501 | a held event staying tentative. Booking `confirmed`: `confirmed` or |
| 502 | `cancelled`. Booking `cancelled` or `expired`: `confirmed` is refused |
| 503 | ("This booking was cancelled. Make a new event instead."); |
| 504 | `tentative` is never accepted from the form for a booked event. Any |
| 505 | update to a soft-deleted event is refused. |
| 506 | - `status = cancelled` on a `confirmed` booking → booking `cancelled`, |
| 507 | `cancelled_by = host`, reminder queue rows deleted, unsent mail |
| 508 | deleted, `(cancelled, booker, seq)` queued. On a `held` booking → |
| 509 | `expired`, unsent mail deleted, nothing queued. |
| 510 | - `DeleteEvent` → the same, plus `sequence = sequence + 1` on the |
| 511 | soft-deleted row; a `held` booking → `expired`. |
| 512 | - `sequence` bumps are `gorm.Expr("sequence + 1")` everywhere. |
| 513 | - The reminder claim path drops a queue row whose event is `cancelled`. |
| 514 | |
| 515 | No revision field on the general event form is needed: the only |
| 516 | fields a stale form could move are refused by the comparison above, |
| 517 | and the rest (title, notes, location) the booker never sees. |
| 518 | |
| 519 | ### 5.6 Reschedule and cancel |
| 520 | |
| 521 | `GET …/reschedule`: the §5.1 picker with the booking's event excluded |
| 522 | from busy, computed with the EVENT's duration and the HOST's current |
| 523 | zone, and a hidden `rev` = `events.sequence`. `POST …/reschedule` with |
| 524 | `start`, `zone`, `rev`. Inside `holdMu`: state `confirmed`; |
| 525 | `reschedules < 5` (else 429); `FreeSlots` check for the response; one transaction that first takes |
| 526 | the write lock with the same no-op write, re-runs the strict busy read |
| 527 | and slot check through the transaction (B12), then `UPDATE events SET |
| 528 | start_local, end_local, tz, start_utc, end_utc, sequence = sequence + 1 |
| 529 | WHERE id = ? AND sequence = ?` (rev) — **all |
| 530 | five time columns together, `tz` = the zone the slot was computed in** |
| 531 | (B21) — zero rows → re-render; `reschedules = reschedules + 1`; unsent |
| 532 | `rescheduled` rows deleted; `(rescheduled, booker + host, seq+1)` |
| 533 | queued; **and for every `Reminder` RULE on the event — not every queue |
| 534 | row, because a fired reminder has none — a queue row is upserted from |
| 535 | the new start in the same transaction** (a `tx` form of |
| 536 | `rescheduleAfter` keyed by `reminder_id`, which the unique index |
| 537 | already enforces one-per-rule; it recomputes `due_at`, clears |
| 538 | `claim_id`, `claimed_at`, `retry_at`, and resets `fired_at`) (B24, |
| 539 | B26). Try both mails after commit. |
| 540 | |
| 541 | `POST …/cancel`: `UPDATE bookings SET state='cancelled', cancelled_at, |
| 542 | cancelled_by='booker' WHERE id=? AND state='confirmed'`; event |
| 543 | `status='cancelled', sequence = sequence + 1`; reminder queue rows |
| 544 | deleted; ALL unsent mail rows deleted; `(cancelled, both, seq)` queued. |
| 545 | A second press affects zero rows. |
| 546 | |
| 547 | ### 5.7 Transitions |
| 548 | |
| 549 | | From | Trigger | To | Guard (in the UPDATE) | Same transaction | |
| 550 | |---|---|---|---|---| |
| 551 | | — | hold | `held` | caps; slot free; `holdMu` | tentative event; snapshot; `mail(hold, booker, 0)` | |
| 552 | | `held` | confirm POST | `confirmed` | `hold_expires_at > now AND now < events.start_utc` | event confirmed, seq+1; attendee; `mail(confirmed, host, seq)` | |
| 553 | | `held` | sweep | `expired` | `hold_expires_at <= now` | event soft-deleted; reminder queue rows deleted; unsent mail deleted | |
| 554 | | `held` | host cancels/deletes event | `expired` | — | unsent mail deleted | |
| 555 | | `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)` | |
| 556 | | `confirmed` | booker cancels | `cancelled` | `state='confirmed'` | event cancelled, seq+1; queue rows deleted; all unsent mail deleted; `cancelled_by=booker`; `mail(cancelled, both, seq)` | |
| 557 | | `confirmed` | host cancels/deletes event | `cancelled` | `state='confirmed'` | seq+1; queue rows deleted; all unsent mail deleted; `cancelled_by=host`; `mail(cancelled, booker, seq)` | |
| 558 | | any | sweep at `ip_forget_at` | same | `ip_fp IS NOT NULL` | `ip_fp = NULL` | |
| 559 | |
| 560 | Every transition is a conditional `UPDATE` checking `RowsAffected == |
| 561 | 1`; zero rows → re-read and render, never retry. No mail is sent inside |
| 562 | a transaction; rows are. |
| 563 | |
| 564 | ### 5.8 The ICS (`WriteBookingICS`) |
| 565 | |
| 566 | A dedicated writer in `booking_ics.go` taking the `publicBooking` |
| 567 | struct and the event's opened UID. `WriteICS` is untouched and still |
| 568 | says `METHOD:PUBLISH` for calendar exports; it is never called here. |
| 569 | |
| 570 | ``` |
| 571 | BEGIN:VCALENDAR / VERSION:2.0 / PRODID / CALSCALE:GREGORIAN |
| 572 | METHOD:REQUEST -- state confirmed, event not cancelled/deleted |
| 573 | METHOD:CANCEL -- otherwise |
| 574 | BEGIN:VEVENT |
| 575 | UID:{event UID} -- stable across reschedules |
| 576 | DTSTAMP:{event updated_at} |
| 577 | SEQUENCE:{event sequence} |
| 578 | DTSTART;TZID={event tz}:{start_local} DTEND;TZID=…:{end_local} |
| 579 | SUMMARY:{snapshot title} DESCRIPTION:{snapshot description, if any} |
| 580 | ORGANIZER:mailto:{host address} |
| 581 | ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:{booker} |
| 582 | STATUS:CONFIRMED | CANCELLED -- effective state, not events.status alone |
| 583 | END:VEVENT / END:VCALENDAR |
| 584 | ``` |
| 585 | |
| 586 | Served at `GET /book/m/{token}/calendar.ics` for `confirmed` and |
| 587 | `cancelled` bookings (`text/calendar; charset=utf-8`, |
| 588 | `Content-Disposition: attachment; filename="booking.ics"`); `held` and |
| 589 | `expired` → 404. The event row is read including soft-deleted ones, so |
| 590 | a host-deleted booking still exports its CANCEL. Folding, TEXT escaping and |
| 591 | date formatting reuse `ics_export.go`'s helpers. **No `CN=` |
| 592 | parameters**: a parameter value has its own quoting rules (RFC 5545 |
| 593 | §3.2, RFC 6868) that `escapeText` does not implement, and a name is |
| 594 | not needed for the invitation to work — `SUMMARY` names the meeting |
| 595 | and the addresses name the people (r4 B6). No `URL:`, no `LOCATION`, |
| 596 | no other attendee. |
| 597 | |
| 598 | --- |
| 599 | |
| 600 | ## 6. Mail |
| 601 | |
| 602 | Plain text through `mail.Sender`; one configured sender; the host's |
| 603 | address is in the BODY and as `ORGANIZER`. Bodies are `text/template` |
| 604 | files in `templates/mail/`, rendered at send time from allowlisted |
| 605 | fields and the CURRENT state (B19). No booker content in any log line. |
| 606 | |
| 607 | | kind | to | Says | |
| 608 | |---|---|---| |
| 609 | | `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}." | |
| 610 | | `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. | |
| 611 | | `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." | |
| 612 | | `cancelled` | booker, host | What, when it had been, `cancelled_by` in words. | |
| 613 | |
| 614 | **Sending.** All mail — reminders included — goes through ONE wrapper |
| 615 | around the configured sender, `boundedSender`: |
| 616 | |
| 617 | - At most **4** sends in flight process-wide (a semaphore). A fifth |
| 618 | caller gets `ErrMailBusy` immediately and its row is retried later. |
| 619 | - Each attempt runs `Send` in a goroutine and waits at most |
| 620 | `min(20s, remaining budget)`, where the budget is the caller's |
| 621 | context deadline. Past that it returns `ErrMailTimeout`; the |
| 622 | goroutine is abandoned and releases its semaphore slot when the |
| 623 | underlying send eventually returns or the OS closes the connection. |
| 624 | - **Honest limit (r3 f3):** rastrillo's `mail.SMTP` ignores its |
| 625 | context and sets no dial or protocol deadline, so an abandoned send |
| 626 | can hold a goroutine and a socket until the kernel gives up. The |
| 627 | semaphore bounds how many. The fix belongs upstream — a `mail.SMTP` |
| 628 | that honours `ctx` — and is recorded in the calendar spec's debt list |
| 629 | in the same commit. This spec does not claim to bound what it cannot. |
| 630 | |
| 631 | Success → acknowledge with the claim id. `ErrMailBusy` → DEFER with |
| 632 | the claim id: due again in 30 seconds, attempts untouched, because no |
| 633 | SMTP call was made and a full wrapper is not a failed address (r4 B4). |
| 634 | Any other error → fail with the claim id (attempts + 1; `due_at` is |
| 635 | already the lease end). Nothing is concluded from an error except "try |
| 636 | again later" (B16). |
| 637 | |
| 638 | **Reminders get the same three outcomes and the same identity** (B22). |
| 639 | `reminder_queues` gains two nullable columns by an additive migration: |
| 640 | `claim_id` (random, set on claim, cleared by any reschedule of the |
| 641 | row) and `retry_at`. `due_at` keeps its meaning — occurrence start |
| 642 | minus offset — and is never written by a retry (r5 f3). Claim |
| 643 | eligibility is `fired_at IS NULL AND due_at <= now AND (retry_at IS |
| 644 | NULL OR retry_at <= now) AND (claimed_at IS NULL OR claimed_at <= now - |
| 645 | claimLease)` (`<=` at both boundaries — r5 f5). **The claim is one |
| 646 | transaction: the conditional UPDATE, then a SELECT of the same row by |
| 647 | id, and the reminder's payload — the occurrence start it announces, |
| 648 | the offset, the event — is built from THAT row, never from the |
| 649 | candidate row the worker read before claiming** (r6 f2): a reschedule |
| 650 | between the candidate read and the claim rewrites the row, the claim |
| 651 | still succeeds, and a payload built from the stale candidate would |
| 652 | announce the old time with a valid claim in hand. The same one |
| 653 | transaction rule applies to `ClaimBookingMail`. `MarkReminderSent`, |
| 654 | `FailReminder` (attempt + 1, `claimed_at` refreshed), the give-up path, |
| 655 | the `ErrMailBusy` deferral (`retry_at = now + 30s`, `claimed_at = |
| 656 | NULL`, attempts untouched) and `RescheduleAfterFiring` are each |
| 657 | `WHERE id = ? AND claim_id = ?` and each checks `RowsAffected`; the |
| 658 | mark and the advance happen in ONE transaction so a worker that loses |
| 659 | its claim between them does neither. Zero rows → the worker stops; its |
| 660 | message was sent and described the old time (§10). |
| 661 | |
| 662 | --- |
| 663 | |
| 664 | ## 7. Abuse |
| 665 | |
| 666 | ### 7.1 Durable caps, from `bookings` rows, inside `holdMu`, before slot arithmetic |
| 667 | |
| 668 | | Cap | Key | Window | Limit | |
| 669 | |---|---|---|---| |
| 670 | | Open holds | `ip_fp` | `state='held' AND hold_expires_at > now` | 5 | |
| 671 | | Open holds | `page_id` | same | 20 | |
| 672 | | Open holds | `booker_address_fp` | same | 2 | |
| 673 | | Attempts | `ip_fp` | `created_at > now - 24h` | 20 | |
| 674 | | Attempts | `booker_address_fp` | `created_at > now - 24h` | 6 | |
| 675 | | Reschedules | booking | lifetime | 5 | |
| 676 | |
| 677 | Over → 429 "Too many bookings in progress. Try again later." |
| 678 | |
| 679 | ### 7.2 The gate (r2 B7, r3 f8) |
| 680 | |
| 681 | An in-process token bucket per `ip_fp` on every public route: 60 |
| 682 | requests/minute, 429 beyond. **Bounded:** at most 10,000 keys; a key |
| 683 | idle for five minutes is evicted on the next insert; when full with no |
| 684 | idle key, a NEW key is refused with 429 and `Retry-After: 60` (r4 |
| 685 | B7). Memory only, resets on restart. The gate also |
| 686 | sets the two public headers. It is the cheap wall in front of the |
| 687 | semaphore (§4.2) and the durable caps (§7.1). |
| 688 | |
| 689 | ### 7.3 What this does not stop |
| 690 | |
| 691 | A patient adversary with many mailboxes and addresses books real slots |
| 692 | one at a time. The host cancels them and, if it continues, unpublishes. |
| 693 | |
| 694 | ### 7.4 The oracle, stated as new |
| 695 | |
| 696 | A published page lets anyone ask when this host is free, and every |
| 697 | granted calendar shapes the answer. Publishing is deliberate: the |
| 698 | button reads **Publish** and the sentence above it reads "Anyone with |
| 699 | this link can see when you are free — not what you are doing, but when |
| 700 | — including time blocked by team calendars you can see." AGENTS.md's |
| 701 | leak list gains this as item 5. |
| 702 | |
| 703 | --- |
| 704 | |
| 705 | ## 8. The sweep |
| 706 | |
| 707 | `drain` (the tick) runs, in order, inside the 45-second `drainDeadline` |
| 708 | shared by everything, **cheap work first, delivery last** (r3 f3): |
| 709 | |
| 710 | 1. **Expire holds**: `held AND hold_expires_at <= now` → `expired`, |
| 711 | event soft-deleted, queue rows deleted, unsent mail deleted. ≤ 200. |
| 712 | 2. **Forget IPs**: `ip_fp IS NOT NULL AND ip_forget_at <= now` → NULL. |
| 713 | ≤ 500. |
| 714 | 3. **Reminders**, as today but through `boundedSender`, until |
| 715 | `deadline/2` or the queue is empty. |
| 716 | 4. **Booking mail**: claim rows `sent_at IS NULL AND due_at <= now` |
| 717 | (lease-aware), oldest first, until the deadline, each attempt |
| 718 | bounded by the remaining budget. |
| 719 | |
| 720 | `DrainOnRequest` (a signed-in page view) does ONE thing: an expiry if |
| 721 | any is due, else a due reminder, else one booking mail. Never more, |
| 722 | and never with more than a 5-second budget. |
| 723 | |
| 724 | `NextDueAt` = the earliest CLAIMABLE instant of: reminders — |
| 725 | `max(due_at, coalesce(retry_at, 0), coalesce(claimed_at + claimLease, |
| 726 | 0))` per unfired row (the existing mismatch, fixed here — r4 A6); next `held` `hold_expires_at`; |
| 727 | next `booking_mails.due_at` with `sent_at IS NULL` (claims advance it, |
| 728 | so it is always claimable); next `ip_forget_at` with `ip_fp IS NOT |
| 729 | NULL`. |
| 730 | |
| 731 | --- |
| 732 | |
| 733 | ## 9. Testing |
| 734 | |
| 735 | - **Parser**: every §3.1 example; inheritance; first clause without a |
| 736 | day; midnight; `25-26`, `9:60`, `13pm`, `24:00` start vs end, |
| 737 | `12:30-2:30`; each separator; the en dash; three ranges; two problems |
| 738 | both reported; merged and touching windows. |
| 739 | - **Slots**: 12–3 / 60 with and without buffer; busy ending 5 min before |
| 740 | `from` still removes the first slot when buffered; tentative blocks; |
| 741 | lapsed unswept hold does not; cancelled does not; a note does not; a |
| 742 | recurring all-day commitment blocks the OCCURRENCE date; an empty-zone |
| 743 | all-day event blocks the host's whole day in `Asia/Tokyo`; notice |
| 744 | floor 10 min; horizon bounds `end`; `to` bounds start; spring-forward |
| 745 | and fold Sundays with 00:00–04:00: no offered slot straddles the |
| 746 | transition, both ends round-trip, every slot is exactly its duration |
| 747 | in elapsed seconds, pinned unix numbers; `DurationMin: 0` and an |
| 748 | inverted window return errors, not lists. |
| 749 | - **Range query**: the moved-override-of-finished-series case; the |
| 750 | long-final-occurrence case; a daily 01:30 Dublin series created 28 |
| 751 | March shows 01:30 on 30 March and on a September day (gap drift); a |
| 752 | fortnightly MO,WE series from Wednesday 2 September shows 14 AND 16 |
| 753 | September; `FREQ=MONTHLY;BYDAY=MO` is foreign, shows once, and fails |
| 754 | the strict reader; the final occurrence across the fold |
| 755 | with `UNTIL` the night before (the 25-hour slack); the 47-hour |
| 756 | two-day weekly event keeps its second day on later occurrences; a |
| 757 | 1700 daily rule → `ErrIncompleteExpansion` from the strict reader |
| 758 | and a 503 from the public page, while `/month` still renders; an |
| 759 | imported busy series with a `BYSETPOS` rule → the same, and the rule |
| 760 | is still stored verbatim. |
| 761 | - **Concurrency**: 20 goroutines, one slot, one winner, `-race`; a |
| 762 | host `CreateEvent` committed between a hold's response-time slot |
| 763 | check and its transaction (inject via a test hook that runs a |
| 764 | callback after the check) → the hold fails with `ErrSlotTaken`, no |
| 765 | tentative event exists; the same for reschedule. |
| 766 | - **Flow**: hold → one mail row, one send; GET link changes nothing; |
| 767 | confirm POST confirms, attendee, default reminder, one host mail; |
| 768 | second POST nothing; confirm after `start` refused; 409 on a taken |
| 769 | slot; wrong token 404 byte-identical; expired says expired; ICS |
| 770 | `METHOD:REQUEST`, `ORGANIZER`, `ATTENDEE …ACCEPTED`, `SEQUENCE` = |
| 771 | event's, no `URL:`, parses with `ParseICS`; ICS for `held` → 404; |
| 772 | reschedule with the right `rev` bumps sequence, writes `tz`, mails |
| 773 | both; the SAME form resubmitted affects nothing; cancel → |
| 774 | `METHOD:CANCEL`, unsent rows gone; host adds note/location/attendee → |
| 775 | none reaches manage, ICS or mail; host cancels via edit → booker |
| 776 | mailed, page says cancelled by host; host deletes → same, CANCEL ICS |
| 777 | with higher sequence; host edits refused: any change to start, end, tz, all_day, |
| 778 | kind or rrule (each tested), reviving a cancelled booking, editing a |
| 779 | deleted event; a scriptless host edit that changes only the title succeeds, |
| 780 | and still succeeds after the host changes their profile zone (the |
| 781 | hidden `tz` is the event's); a stale host form submitted after a |
| 782 | public reschedule is refused; the event form shows the scheduling |
| 783 | controls disabled AND carries hidden inputs for each, for a booked |
| 784 | event; page title changed after hold → |
| 785 | manage and ICS still show the snapshot. |
| 786 | - **Zone change**: host moves Dublin → Tokyo; reschedule writes |
| 787 | `tz=Asia/Tokyo` and all four time columns agree (`ToUTC(start_local, |
| 788 | tz) == start_utc`). |
| 789 | - **Mail queue**: token recoverable after a simulated restart (insert |
| 790 | row, `tick`, link works); a refused host mail is retried at `due_at` |
| 791 | and gives up at `MaxReminderAttempts`; a stale `claim_id` cannot |
| 792 | acknowledge or fail a row another claim holds; two drains do not |
| 793 | send one row twice; cancel deletes every unsent row; `NextDueAt` |
| 794 | reflects `due_at` not the lease; `boundedSender` returns |
| 795 | `ErrMailTimeout` within budget against a stalling sender and |
| 796 | `ErrMailBusy` when four are in flight; a row deferred by |
| 797 | `ErrMailBusy` keeps `attempts = 0` and sends once capacity returns; |
| 798 | a crash after a claim (simulated: claim, do not ack) leaves |
| 799 | `NextDueAt` at the lease end, not now. |
| 800 | - **Caps, gate, semaphore**: sixth open hold per IP → 429; 21st attempt |
| 801 | in 24h → 429 with none open; `MaxReschedules`; 61st public GET in a |
| 802 | minute → 429; the gate holds ≤ 10,000 keys after 20,000 distinct IPs; |
| 803 | `?day=` past the horizon → 404; five concurrent public slot |
| 804 | computations → one 503 with `Retry-After`. |
| 805 | - **Sweep**: `tick` expires, soft-deletes, frees; retries owed mail; |
| 806 | nulls `ip_fp` at `ip_forget_at`; reminders still progress with 30 |
| 807 | mail rows queued behind a slow sender; `DrainOnRequest` does one |
| 808 | thing; `NextDueAt` returns each source when earliest. |
| 809 | - **`Zone()`**: 100 goroutines × garbage under `-race`; no failures |
| 810 | cached. |
| 811 | - **Reminders**: a cancelled event's queue row is dropped by the claim |
| 812 | path; a worker that claimed a row which was then rescheduled AND |
| 813 | re-claimed by another worker in the same second cannot mark, fail, |
| 814 | defer or advance it (`claim_id`, not `claimed_at`); a reschedule |
| 815 | followed by a simulated crash (no post-commit code) leaves the queue |
| 816 | row already pointing at the new time; a reminder that FIRED (queue row |
| 817 | deleted) before the booker moved the meeting to tomorrow gets a new |
| 818 | queue row for tomorrow; a reschedule that lands between |
| 819 | a worker's candidate read and its claim makes the worker announce the |
| 820 | NEW time (the payload comes from the claimed row); a title-only host |
| 821 | save of a HELD event succeeds and leaves it tentative; a host attempt |
| 822 | to set a held event `confirmed` is refused; an `ErrMailBusy` deferral |
| 823 | leaves `due_at` untouched and the reconstructed meeting time correct; |
| 824 | a claim exactly `claimLease` old is claimable; `NextDueAt` for a |
| 825 | claimed reminder is the lease end. |
| 826 | - **ICS**: a booker named `Doe, Jane "JJ": Support` produces a file |
| 827 | the app's parser AND a line-by-line check accept, with the address |
| 828 | unchanged and no `CN=`. |
| 829 | - **The one rule**: booker name, address, note, the token, the |
| 830 | snapshotted title and description, the page's availability — none in |
| 831 | the raw bytes. |
| 832 | - **Logs**: no address, no token, after a failed send. |
| 833 | - **Browser**: JS off — book, "check your mail", open the link, press |
| 834 | Confirm, see Reschedule. JS on — the settings pane fills in. |
| 835 | |
| 836 | --- |
| 837 | |
| 838 | ## 10. What is NOT in this version |
| 839 | |
| 840 | - Group / round-robin pages; booker custom fields; payments; video |
| 841 | links; reminders to the booker. |
| 842 | - **Host-initiated reschedule.** A booked event's time belongs to the |
| 843 | booking flow (B18); the host's form cannot move it, and says so |
| 844 | beside the disabled controls. To move a booked meeting the host |
| 845 | cancels and lets the booker rebook. Cancel and delete DO tell the |
| 846 | booker. |
| 847 | - **Automatic calendar sync.** The ICS is a download; the change mail |
| 848 | says to download it again. |
| 849 | - **A revision field on the general event form.** Booked events are |
| 850 | protected by the field lock instead (§5.5). |
| 851 | - **A context-honouring SMTP sender.** Upstream (§6). |
| 852 | - **Recalling a reminder mid-send.** A reminder that was already |
| 853 | handed to the sender when the booker rescheduled describes the old |
| 854 | time; the reschedule mail that follows describes the new one. |
| 855 | - Locale; retention (rows stay, sealed, minus `ip_fp`). |
| 856 | |
| 857 | --- |
| 858 | |
| 859 | ## 11. Changes to AGENTS.md and the calendar spec in the same commit |
| 860 | |
| 861 | - Settled decisions: rows B1, B2, B4, B5, B8, B11–B21. |
| 862 | - Frontend: **"Except a BOOKER, who typed their address to be mailed. |
| 863 | Booking mail is the one outbound to a non-team address."** |
| 864 | - Data: **"Booker name, address, note and TOKEN are sealed; the address |
| 865 | has the keyed fingerprint. A page's public code is generated, never a |
| 866 | chosen slug. Windows are plaintext: shape-of-time."** |
| 867 | - Data: **"Every booking transition is a conditional UPDATE naming the |
| 868 | state and revision it expects, checking RowsAffected. Zero rows is |
| 869 | 'someone moved it first', never a retry."** |
| 870 | - Data: **"A booked event's start, end, tz, all_day, kind and rrule |
| 871 | belong to the booking flow. A host edit that changes any of them is |
| 872 | refused; the form shows them disabled."** |
| 873 | - Recurrence: **"`Expand` may stop early. Anything that decides FREE |
| 874 | time reads `EventsInRangeStrict` and fails closed on |
| 875 | `ErrIncompleteExpansion`."** |
| 876 | - Recurrence: **"A day span is a difference of CIVIL dates, never |
| 877 | elapsed hours over 24."** |
| 878 | - Reminders → **Ticks**: **"`NextDue` is the earliest CLAIMABLE |
| 879 | instant across reminders, hold expiries, owed booking mail and |
| 880 | IP-forgets. New scheduled work goes here, never in a goroutine. |
| 881 | Cheap work runs before delivery; reminders get the first half of the |
| 882 | delivery budget."** |
| 883 | - Reminders: **"No sender error is definite. Never free a resource |
| 884 | because a send 'failed'. Every send goes through `boundedSender`. |
| 885 | `ErrMailBusy` is a deferral, not an attempt."** |
| 886 | - Reminders: **"Every queue row has a random `claim_id`; every |
| 887 | completion mutation is conditional on it and checks RowsAffected, |
| 888 | and a payload is built from the row the claim returned, never from |
| 889 | the candidate read. A reminder's `due_at` is the occurrence minus the |
| 890 | offset and is never written by a retry — `retry_at` is. Booking |
| 891 | mail's `due_at` is retry eligibility only and has no `retry_at`."** |
| 892 | - Reminders: **"A reschedule repairs the event's queue rows in the |
| 893 | same transaction as the move."** |
| 894 | - Time: **"`Zone()` takes bounded input on public routes: `CommonZones` |
| 895 | plus the host's own zone."** |
| 896 | - Time: **"A booking slot is the same length on the wall clock and the |
| 897 | elapsed clock, or it is not offered."** |
| 898 | - The one rule's exclusions, item 5: the published-page oracle. |
| 899 | - `2026-09-02-calendar-design.md` §7 debt list: **"rastrillo `mail.SMTP` |
| 900 | ignores its context; an abandoned send holds a goroutine and a |
| 901 | socket until the kernel closes it. Bounded to four in flight here; |
| 902 | fixed properly upstream."** |
| 903 | |
| 904 | --- |
| 905 | |
| 906 | ## 12. Review |
| 907 | |
| 908 | Reviewer: **GPT-6** via `codex-cli 0.153.0` (identified itself as |
| 909 | `gpt-6-astra` in round 2; rounds 1 and 3 could not expose the runtime |
| 910 | identifier). 2026-09-07. |
| 911 | |
| 912 | **Round 1: REWRITE, 21 findings, all accepted.** **Round 2: REWRITE, |
| 913 | 7 new + 11 reopened, all accepted.** Both tables are in the git history |
| 914 | of this file (`9a28d7f`, `0999c0d`, `13b43a4`). |
| 915 | |
| 916 | **Round 3: REWRITE.** 11 new findings; of round 2, A11, A21, B2, B7 |
| 917 | PARTIAL and B4 DOES NOT HOLD. Dispositions: |
| 918 | |
| 919 | | Ref | Finding | Disposition | |
| 920 | |---|---|---| |
| 921 | | 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). | |
| 922 | | 2 | Reschedule after a host zone change writes a contradictory record | **Accepted — B21.** `tz` written with the four time columns (§5.6). | |
| 923 | | 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). | |
| 924 | | 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. | |
| 925 | | 5 | Ordinary host edits contradict terminal booking states | **Accepted — B18.** Reviving refused; deleted events refused; no general-form revision, stated in §10. | |
| 926 | | 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). | |
| 927 | | 7 | `UNTIL` exclusion misses a long final occurrence | **Accepted, a second calendar bug.** `until_utc + duration < from` (§4.1). | |
| 928 | | 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. | |
| 929 | | 9 | ICS contract missing; held/expired undefined; effective status | **Accepted.** §5.8. | |
| 930 | | 10 | Live page text changes the ICS without a new `SEQUENCE` | **Accepted — B20.** Title and description snapshotted onto the booking. | |
| 931 | | 11 | Token generation unspecified | **Accepted — B14.** 32 bytes `crypto/rand`, hex, collision handling. | |
| 932 | |
| 933 | Rejected: none. Scoped rather than solved: the socket-level send |
| 934 | bound (finding 3, upstream) and the per-request cost of recurrence |
| 935 | expansion (finding 8, the calendar's own). |
| 936 | |
| 937 | **Round 4: REWRITE.** 7 new (6 blockers, 1 hardening); of round 3, |
| 938 | A5, A6, A7 PARTIAL; 3 and 8 HOLD as scoped. Dispositions: |
| 939 | |
| 940 | | Ref | Finding | Disposition | |
| 941 | |---|---|---| |
| 942 | | 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). | |
| 943 | | 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). | |
| 944 | | A7 | Master duration is not a conservative bound across DST | **Accepted.** 25-hour slack on the `UNTIL` exclusion (§4.1). | |
| 945 | | B1 | Kind → `note` makes a confirmed booking non-busy | **Accepted — B18.** `kind` is a locked field. | |
| 946 | | B2 | `daysBetween` loses a day across spring DST | **Accepted, a third calendar bug.** Civil-date difference (§4.1). | |
| 947 | | B3 | A reminder finishing mid-reschedule deletes the replacement | **Accepted — B22.** Mark and advance conditional on the claim (§6). | |
| 948 | | B4 | `ErrMailBusy` burns attempts without reaching SMTP | **Accepted — B22.** Defer path, both queues (§2.5, §6). | |
| 949 | | B5 | `Expand` truncation read as free time | **Accepted — B23.** `ExpandComplete`, `EventsInRangeStrict`, 503 fail-closed (§4.1). | |
| 950 | | B6 | `CN=` cannot reuse TEXT escaping | **Accepted by omission.** No `CN=` (§5.8). | |
| 951 | | B7 | Full bucket with no idle key undefined | **Accepted.** New key refused, 429 + `Retry-After` (§7.2). | |
| 952 | |
| 953 | Rejected: none. |
| 954 | |
| 955 | **Round 5: REWRITE.** 4 blockers, 2 hardening; of round 4, A6, B3, |
| 956 | B4 PARTIAL. Dispositions: |
| 957 | |
| 958 | | Ref | Finding | Disposition | |
| 959 | |---|---|---| |
| 960 | | 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). | |
| 961 | | f2 | Reschedule commits before the reminder repair | **Accepted — B24.** Queue rows rebuilt inside the reschedule transaction (§5.6). | |
| 962 | | 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). | |
| 963 | | 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). | |
| 964 | | f5 | `<` vs `<=` at the lease boundary | **Accepted.** `<=` both sides (§6). | |
| 965 | | f6 | §10 still described host rescheduling | **Accepted.** Rewritten (§10). | |
| 966 | |
| 967 | Rejected: none. |
| 968 | |
| 969 | **Round 6: REWRITE.** 3 blockers, 1 wording; round 5's six all HOLD. |
| 970 | |
| 971 | | Ref | Finding | Disposition | |
| 972 | |---|---|---| |
| 973 | | 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). | |
| 974 | | 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). | |
| 975 | | f3 | A held event has no permitted unchanged status | **Accepted.** Status rules keyed on booking state; host cannot promote a hold (§5.5). | |
| 976 | | f4 | B22/§11 said `retry_at` for both queues | **Accepted.** Wording aligned (B22, §11). | |
| 977 | |
| 978 | Rejected: none. |
| 979 | |
| 980 | **Round 7: REWRITE.** 3 blockers, 0 polish; round 6's four HOLD. |
| 981 | |
| 982 | | Ref | Finding | Disposition | |
| 983 | |---|---|---| |
| 984 | | f1 | Spring-gap resolution drifts every later occurrence | **Accepted, a fourth calendar bug.** Advancement carries the original civil clock (§4.1). | |
| 985 | | 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). | |
| 986 | | f3 | Rebuilding queue rows misses a fired reminder | **Accepted — B26.** Enumerate `Reminder` rules; upsert (§5.6). | |
| 987 | |
| 988 | Rejected: none. |
| 989 | |
| 990 | **Round 8: REWRITE.** 2 blockers, both recurrence-engine bugs; |
| 991 | round 7's three HOLD. |
| 992 | |
| 993 | | Ref | Finding | Disposition | |
| 994 | |---|---|---| |
| 995 | | 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). | |
| 996 | | f2 | `ParseRule` accepts combinations the expander misreads | **Accepted, a sixth.** Combination validation → `ErrForeignRule`; strict fails closed (§4.1). | |
| 997 | |
| 998 | Rejected: none. |
| 999 | |
| 1000 | **Spec review closed at revision 9.** Eight rounds, 58 findings, 58 |
| 1001 | accepted (two scoped). The design has been stable since revision 5; |
| 1002 | rounds 6–8 moved transaction boundaries and found six bugs in the |
| 1003 | recurrence engine that every calendar view already had. Further review |
| 1004 | is of the branch's code, recorded in the calendar spec's review log. |
| 1005 | |