oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Booking pages — design
2
3**Status:** specified 2026-09-07, before the code. Authored by Claude
4Fable 5.1. Approved in outline by Paul in the session that wrote it
5(three questions, answered: confirm by email link; everything the host
6can see counts as busy; pages are configurable per person). **Revision
79.** Reviewed eight times by GPT-6 (`gpt-6-astra` on the one run that
8exposed its identifier) through `codex-cli 0.153.0`; all eight returned
9REWRITE, each round narrower than the last. Rounds 7 and 8 found
10nothing in the booking design itself: every finding was a pre-existing
11bug in the recurrence engine the design depends on, and the spec owns
12them (§4.1). The spec rounds end here; the next review is of the code. §12 records every finding
13and disposition, including the two scoped rather than solved.
14
15Asked for: a Calendly / Cal.com style "book time with me" page to share
16with 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
18out. The booker gets a meeting invitation as ICS, and can reschedule or
19cancel.
20
21This document is binding on the implementation. Where it disagrees with
22AGENTS.md, AGENTS.md is wrong and is fixed in the same commit (§11).
23
24---
25
26## 0. The shape in one paragraph
27
28Each person owns any number of **booking pages**, made in Settings.
29A page has a title, a duration, an availability sentence, and three
30knobs with defaults. It is public at `/book/{handle}/{code}`, where the
31code is generated. A stranger picks a slot in their own zone, types a
32name and address, and the slot is **held** as a tentative event on the
33host's personal calendar. One mail goes to the booker with one link.
34The link shows a page with one button; pressing it **confirms**: the
35event becomes confirmed, the booker becomes an attendee, the host is
36told, and the same page is where the booker downloads the ICS,
37reschedules, or cancels. A hold that is not confirmed expires — after
38thirty minutes, or when the meeting would have started, whichever is
39first — 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
78All migrations additive, generated.
79
80### 2.1 `people.handle`
81
82Nullable, unique, plaintext, `[a-z0-9-]{2,32}`, lowercased on save.
83Reserved: `m`, `new`, `static`. Changing it changes every public URL
84and the settings page says so.
85
86### 2.2 `booking_pages`
87
88```
89id, person_id (idx), code, title_sealed, description_sealed,
90duration_min, buffer_min, notice_min, horizon_days,
91availability_text_sealed, published_at (nullable), created_at, updated_at
92UNIQUE (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```
109id, page_id (idx), weekday (0=Sunday..6), start_min, end_min
110```
111
112Plaintext (shape-of-time). `0 <= start_min < end_min <= 1440`.
113Rewritten wholesale on save, **merged** first.
114
115### 2.4 `bookings`
116
117```
118id, page_id (idx), event_id (idx), token_hash UNIQUE, token_sealed, state (idx),
119title_sealed, description_sealed,
120booker_name_sealed, booker_address_sealed, booker_address_fp (idx),
121note_sealed, booker_zone,
122hold_expires_at (idx), confirmed_at, cancelled_at, cancelled_by,
123ip_fp (idx, nullable), ip_forget_at (idx), reschedules,
124created_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```
145id, booking_id (idx), kind, recipient, revision,
146due_at (idx), attempts, claimed_at, claim_id, sent_at, created_at
147UNIQUE (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
186Every settings read or write of a page is `WHERE id = ? AND person_id
187= ?` → `ErrNotFound` → 404. `PublicBookingPage(handle, code)` is the
188only 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
191never touches an existing event; a reschedule keeps the event's
192duration. 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
197The instance listens on a unix socket only the platform edge reaches;
198the edge calls `httputil.ProxyRequest.SetXForwarded`, which REPLACES
199`X-Forwarded-For` with the dialling client (`platform/internal/edge/
200homemap.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
210Clauses split on `,` and `;`. Each clause is a **day part** then one or
211more **time ranges**; a clause with no day part inherits the previous
212clause's days; a first clause with none is a problem.
213
214```
215mondays between 12–3, tuesdays 12–2, 5-7
216mon-wed 9-5
217weekdays 10am to 4pm
218every day 8–6
219saturday 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
239Every unconsumed token is a problem, quoted. All problems are
240reported. 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`:
245Go reads a `_windows.go` suffix as a GOOS constraint and never compiles
246the file on Linux — found when the first build could not see it). `ParseAvailability
247(text) (windows []Window, problems []string)`, pure, merged, sorted.
248Shares `consumed`, `weekdayAlt`, `weekdays`, `rangeSep` with quick add;
249does NOT call `clock`/`applyMeridiem`.
250
251### 3.4 The pane
252
253`POST /settings/booking/parse` → fragment: windows in words plus every
254problem. `booking.js` is a debounced fetch cloned from `quickadd.js`.
255Scriptless: 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
2640. **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.
2681. **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.
2722. **Days.** Host-zone calendar days from `from` to `to`, by
273 `AddDate(0,0,1)` on a local midnight.
2743. **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()`.
2814. **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.
2905. **Buffer.** Drop if `[start - buffer, end + buffer)` overlaps busy.
2916. 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
296Both are calendar bugs, visible in every view, owned here because busy
297time 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
363Regression tests in `occurrence_test.go`: a daily series ending in
364three days with its last occurrence moved ten days out shows on that
365day; a series whose final occurrence spans four days shows on its
366third; a weekly two-day Dublin event created 28 March 09:00 – 30 March
36709:00 still occupies 30 March on its first AND a later occurrence; a
368daily rule with `DTSTART` in 1700 makes `EventsInRangeStrict` return
369`ErrIncompleteExpansion` for a 2026 window while `EventsInRange` still
370answers.
371
372### 4.2 Cost
373
374`?day=` outside `[today, today + horizon_days]` in the booker's zone →
375404. The day list is ONE `EventsInRange` over the horizon; a day page
376ONE over that day ±1. Every public route passes the gate in §7.2. A
377process-wide semaphore of **4** concurrent public slot computations
378guards `FreeSlots` from public handlers; when it is full the response
379is **503** with `Retry-After: 5`, and never a page computed from
380truncated busy data (r3 f8).
381
382---
383
384## 5. The public flow
385
386Public routes sit beside `/feed/{token}.ics`: inside `csrf.Protect`,
387outside the session group, each wrapped by the gate (§7.2), which also
388sets `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. The
389token is in the path and WILL be in the access log — the feed's own
390residual, stated.
391
392### 5.1 `GET /book/{handle}/{code}`
393
394`PublicBookingPage` → 404 byte-identical to the router's own. The page:
395title, description, duration, host's first name (opened `NameSealed`)
396or handle, a zone `<select>` of `CommonZones` plus the host's zone;
397`?zone=` outside that set → the host's zone.
398
399No `?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`.
402Pressing one posts WITHOUT `name`; the handler re-verifies the slot and
403renders 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
409Body ≤ 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
413Inside `holdMu` (B12): §7.1 caps → 429; `FreeSlots` for that day for
414the RESPONSE (so a taken slot can be reported without a write lock);
415`start` present → else 409 "That time has just gone". Then ONE
416transaction that (1) acquires the write lock first with a no-op write
417(`UPDATE booking_pages SET updated_at = updated_at WHERE id = ?`) —
418SQLite begins deferred, and a read inside a deferred transaction does
419not stop another writer committing under it (r7 f2); (2) re-runs the
420strict busy read and the slot check for that day THROUGH the
421transaction, and rolls back with `ErrSlotTaken` if the slot has gone;
422(3) inserts the tentative event (title `"{page title} — {name}"`,
423notes = the note, kind meeting, `tz` = host zone, local strings from
424the instant), the booking (`held`, `hold_expires_at = min(now + 30m,
425start)`, token hashed and sealed, title and description snapshotted,
426`ip_fp`, `ip_forget_at`), and `booking_mails(hold, booker, 0)`.
427
428Outside the mutex: try the row once (§6). **Whatever happens, the hold
429stands** (B16). Response 200 "Check your mail" with the address and
430the expiry in the booker's zone; if the try errored, plus "If nothing
431arrives in a few minutes, the hold lapses at {expiry} and you can try
432again."
433
434### 5.3 `GET /book/m/{token}`
435
436`BookingByToken` → 404. Reads the event ROW for `status`, `deleted_at`,
437times, `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
451One transaction: read `events.start_utc`; `UPDATE bookings SET
452state='confirmed', confirmed_at WHERE id=? AND state='held' AND
453hold_expires_at > ? AND ? < start_utc`; `RowsAffected == 1` or stop and
454re-render §5.3. Then `UPDATE events SET status='confirmed', sequence =
455sequence + 1`; attendee row; queue `(confirmed, host, seq)`. After
456commit: the host's default reminder (its own function; the booking is
457correct without it). Then try the row once. A second press affects
458zero rows and does nothing.
459
460### 5.5 The manage page, the allowlist, and the host's authority
461
462Manage shows: the snapshotted title and description, when (booker
463zone; 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
467may contain ONLY: the booking's snapshotted title and description, the
468event's `start`/`end`/`status`/`sequence`/`deleted_at`, the host's
469name/handle and address, the booker's own name and address,
470`cancelled_by`, the booking's state. **Never** the event's title,
471notes, location or attendee list. Public templates receive a
472`publicBooking` struct with exactly those fields; they never receive an
473event or a booking. A test edits notes, location and attendees as the
474host after confirmation and asserts none reaches manage, ICS or mail.
475
476**The host's authority (B18).** The host edits the event through the
477ordinary form, and `UpdateEvent`/`DeleteEvent` enforce, inside their
478transactions, 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
515No revision field on the general event form is needed: the only
516fields a stale form could move are refused by the comparison above,
517and 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
522from busy, computed with the EVENT's duration and the HOST's current
523zone, 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
526the write lock with the same no-op write, re-runs the strict busy read
527and slot check through the transaction (B12), then `UPDATE events SET
528start_local, end_local, tz, start_utc, end_utc, sequence = sequence + 1
529WHERE id = ? AND sequence = ?` (rev) — **all
530five 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)`
533queued; **and for every `Reminder` RULE on the event — not every queue
534row, because a fired reminder has none — a queue row is upserted from
535the new start in the same transaction** (a `tx` form of
536`rescheduleAfter` keyed by `reminder_id`, which the unique index
537already enforces one-per-rule; it recomputes `due_at`, clears
538`claim_id`, `claimed_at`, `retry_at`, and resets `fired_at`) (B24,
539B26). Try both mails after commit.
540
541`POST …/cancel`: `UPDATE bookings SET state='cancelled', cancelled_at,
542cancelled_by='booker' WHERE id=? AND state='confirmed'`; event
543`status='cancelled', sequence = sequence + 1`; reminder queue rows
544deleted; ALL unsent mail rows deleted; `(cancelled, both, seq)` queued.
545A 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
560Every transition is a conditional `UPDATE` checking `RowsAffected ==
5611`; zero rows → re-read and render, never retry. No mail is sent inside
562a transaction; rows are.
563
564### 5.8 The ICS (`WriteBookingICS`)
565
566A dedicated writer in `booking_ics.go` taking the `publicBooking`
567struct and the event's opened UID. `WriteICS` is untouched and still
568says `METHOD:PUBLISH` for calendar exports; it is never called here.
569
570```
571BEGIN:VCALENDAR / VERSION:2.0 / PRODID / CALSCALE:GREGORIAN
572METHOD:REQUEST -- state confirmed, event not cancelled/deleted
573METHOD:CANCEL -- otherwise
574BEGIN:VEVENT
575UID:{event UID} -- stable across reschedules
576DTSTAMP:{event updated_at}
577SEQUENCE:{event sequence}
578DTSTART;TZID={event tz}:{start_local} DTEND;TZID=…:{end_local}
579SUMMARY:{snapshot title} DESCRIPTION:{snapshot description, if any}
580ORGANIZER:mailto:{host address}
581ATTENDEE;PARTSTAT=ACCEPTED;RSVP=FALSE:mailto:{booker}
582STATUS:CONFIRMED | CANCELLED -- effective state, not events.status alone
583END:VEVENT / END:VCALENDAR
584```
585
586Served 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
590a host-deleted booking still exports its CANCEL. Folding, TEXT escaping and
591date formatting reuse `ics_export.go`'s helpers. **No `CN=`
592parameters**: a parameter value has its own quoting rules (RFC 5545
593§3.2, RFC 6868) that `escapeText` does not implement, and a name is
594not needed for the invitation to work — `SUMMARY` names the meeting
595and the addresses name the people (r4 B6). No `URL:`, no `LOCATION`,
596no other attendee.
597
598---
599
600## 6. Mail
601
602Plain text through `mail.Sender`; one configured sender; the host's
603address is in the BODY and as `ORGANIZER`. Bodies are `text/template`
604files in `templates/mail/`, rendered at send time from allowlisted
605fields 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
615around 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
631Success → acknowledge with the claim id. `ErrMailBusy` → DEFER with
632the claim id: due again in 30 seconds, attempts untouched, because no
633SMTP call was made and a full wrapper is not a failed address (r4 B4).
634Any other error → fail with the claim id (attempts + 1; `due_at` is
635already the lease end). Nothing is concluded from an error except "try
636again 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
641row) and `retry_at`. `due_at` keeps its meaning — occurrence start
642minus offset — and is never written by a retry (r5 f3). Claim
643eligibility is `fired_at IS NULL AND due_at <= now AND (retry_at IS
644NULL OR retry_at <= now) AND (claimed_at IS NULL OR claimed_at <= now -
645claimLease)` (`<=` at both boundaries — r5 f5). **The claim is one
646transaction: the conditional UPDATE, then a SELECT of the same row by
647id, and the reminder's payload — the occurrence start it announces,
648the offset, the event — is built from THAT row, never from the
649candidate row the worker read before claiming** (r6 f2): a reschedule
650between the candidate read and the claim rewrites the row, the claim
651still succeeds, and a payload built from the stale candidate would
652announce the old time with a valid claim in hand. The same one
653transaction rule applies to `ClaimBookingMail`. `MarkReminderSent`,
654`FailReminder` (attempt + 1, `claimed_at` refreshed), the give-up path,
655the `ErrMailBusy` deferral (`retry_at = now + 30s`, `claimed_at =
656NULL`, attempts untouched) and `RescheduleAfterFiring` are each
657`WHERE id = ? AND claim_id = ?` and each checks `RowsAffected`; the
658mark and the advance happen in ONE transaction so a worker that loses
659its claim between them does neither. Zero rows → the worker stops; its
660message 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
677Over → 429 "Too many bookings in progress. Try again later."
678
679### 7.2 The gate (r2 B7, r3 f8)
680
681An in-process token bucket per `ip_fp` on every public route: 60
682requests/minute, 429 beyond. **Bounded:** at most 10,000 keys; a key
683idle for five minutes is evicted on the next insert; when full with no
684idle key, a NEW key is refused with 429 and `Retry-After: 60` (r4
685B7). Memory only, resets on restart. The gate also
686sets the two public headers. It is the cheap wall in front of the
687semaphore (§4.2) and the durable caps (§7.1).
688
689### 7.3 What this does not stop
690
691A patient adversary with many mailboxes and addresses books real slots
692one at a time. The host cancels them and, if it continues, unpublishes.
693
694### 7.4 The oracle, stated as new
695
696A published page lets anyone ask when this host is free, and every
697granted calendar shapes the answer. Publishing is deliberate: the
698button reads **Publish** and the sentence above it reads "Anyone with
699this 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
701leak 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`
708shared by everything, **cheap work first, delivery last** (r3 f3):
709
7101. **Expire holds**: `held AND hold_expires_at <= now` → `expired`,
711 event soft-deleted, queue rows deleted, unsent mail deleted. ≤ 200.
7122. **Forget IPs**: `ip_fp IS NOT NULL AND ip_forget_at <= now` → NULL.
713 ≤ 500.
7143. **Reminders**, as today but through `boundedSender`, until
715 `deadline/2` or the queue is empty.
7164. **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
721any is due, else a due reminder, else one booking mail. Never more,
722and 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,
7260))` per unfired row (the existing mismatch, fixed here — r4 A6); next `held` `hold_expires_at`;
727next `booking_mails.due_at` with `sent_at IS NULL` (claims advance it,
728so it is always claimable); next `ip_forget_at` with `ip_fp IS NOT
729NULL`.
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
908Reviewer: **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
910identifier). 2026-09-07.
911
912**Round 1: REWRITE, 21 findings, all accepted.** **Round 2: REWRITE,
9137 new + 11 reopened, all accepted.** Both tables are in the git history
914of this file (`9a28d7f`, `0999c0d`, `13b43a4`).
915
916**Round 3: REWRITE.** 11 new findings; of round 2, A11, A21, B2, B7
917PARTIAL 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
933Rejected: none. Scoped rather than solved: the socket-level send
934bound (finding 3, upstream) and the per-request cost of recurrence
935expansion (finding 8, the calendar's own).
936
937**Round 4: REWRITE.** 7 new (6 blockers, 1 hardening); of round 3,
938A5, 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
953Rejected: none.
954
955**Round 5: REWRITE.** 4 blockers, 2 hardening; of round 4, A6, B3,
956B4 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
967Rejected: 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
978Rejected: 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
988Rejected: none.
989
990**Round 8: REWRITE.** 2 blockers, both recurrence-engine bugs;
991round 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
998Rejected: none.
999
1000**Spec review closed at revision 9.** Eight rounds, 58 findings, 58
1001accepted (two scoped). The design has been stable since revision 5;
1002rounds 6–8 moved transaction boundaries and found six bugs in the
1003recurrence engine that every calendar view already had. Further review
1004is of the branch's code, recorded in the calendar spec's review log.
1005