booking: spec revision 3 after the second review
gpt-6-astra returned REWRITE again: 7 new findings and 11 of the 21 earlier ones partial or not holding. The new ones were structural. The mail outbox could not rebuild a link it held only a hash of, so the token is now sealed as well as hashed. One mutable mail marker lost a cancellation that landed mid-send and let three drains send one mail, so mail is a queue table with a lease — the reminder queue's own shape. A five-minute slot held at 09:59 could be confirmed at 10:20, so a hold expires at the meeting's start and confirmation requires now < start. smtp.SendMail can fail after acceptance, so no sender error is definite and nothing is freed on one. A 00:30 slot on the fold Sunday stored as two hours, so both ends round-trip and a slot that straddles a transition is not offered. And the range query drops a moved occurrence of a finished series, in every view, which is a calendar bug this spec now owns.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 file changed,
+473
−455
docs/superpowers/specs/2026-09-07-booking-design.md+473 −455
diff --git a/docs/superpowers/specs/2026-09-07-booking-design.md b/docs/superpowers/specs/2026-09-07-booking-design.md| index 6d093f7..90456ce 100644 |
| --- a/docs/superpowers/specs/2026-09-07-booking-design.md |
| +++ b/docs/superpowers/specs/2026-09-07-booking-design.md |
| @@ -4,8 +4,8 @@ |
| Fable 5.1. Approved in outline by Paul in the session that wrote it |
| (three questions, answered: confirm by email link; everything the host |
| can see counts as busy; pages are configurable per person). **Revision |
| -2** after a review by GPT-6 through `codex-cli 0.153.0` returned |
| -REWRITE with 21 findings; §12 records each and what changed. |
| +3.** Reviewed twice by `gpt-6-astra` through `codex-cli 0.153.0`; both |
| +rounds returned REWRITE. §12 records every finding and what changed. |
| Asked for: a Calendly / Cal.com style "book time with me" page to share |
| with people outside the team. The host sets availability in plain text |
| @@ -23,14 +23,15 @@ AGENTS.md, AGENTS.md is wrong and is fixed in the same commit (§11). |
| Each person owns any number of **booking pages**, made in Settings. |
| A page has a title, a duration, an availability sentence, and three |
| knobs with defaults. It is public at `/book/{handle}/{code}`, where the |
| -code is generated, not chosen. A stranger picks a slot in their own |
| -zone, types a name and address, and the slot is **held** as a tentative |
| -event on the host's personal calendar. One mail goes to the booker with |
| -one link. The link shows a page with one button; pressing it |
| -**confirms**: the event becomes confirmed, the booker becomes an |
| -attendee, the host is told, and the same page is where the booker |
| -downloads the ICS, reschedules, or cancels. Unclaimed holds expire in |
| -thirty minutes and are swept on the existing tick. |
| +code is generated. A stranger picks a slot in their own zone, types a |
| +name and address, and the slot is **held** as a tentative event on the |
| +host's personal calendar. One mail goes to the booker with one link. |
| +The link shows a page with one button; pressing it **confirms**: the |
| +event becomes confirmed, the booker becomes an attendee, the host is |
| +told, and the same page is where the booker downloads the ICS, |
| +reschedules, or cancels. A hold that is not confirmed expires — after |
| +thirty minutes, or when the meeting would have started, whichever is |
| +first — and is swept on the existing tick. |
| --- |
| @@ -38,35 +39,35 @@ thirty minutes and are swept on the existing tick. |
| | # | Ruling | Rejected | Why | |
| |---|---|---|---| |
| -| B1 | **A booking is confirmed by a button on the page the mailed link opens** — a same-origin POST — never on submit and never on GET | Instant confirmation; confirming on the GET itself | Any typed address would fill a calendar. And a GET that confirms is confirmed by every link-scanning mail gateway on earth, so a stranger typing YOUR address would book you a meeting you never saw (review finding 2). The mailed link is a request; the button is the consent. | |
| +| 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 | Any typed address would fill a calendar. A GET that confirms is confirmed by every link-scanning mail gateway, so a stranger typing YOUR address books you a meeting you never saw (r1 f2). The mailed link is a request; the button is the consent. | |
| | B2 | **Everything the host can see is busy** — every calendar they hold a grant on | Personal only; a per-page checklist | Nobody gets booked over the team standup. The page reveals only that a slot is gone, never by what. | |
| -| B3 | **Pages are per person, any number** | One page per person | "15 min chat" and "60 min call" are different pages with different windows. | |
| -| B4 | **The public identifier is a chosen plaintext `handle` plus a GENERATED page code** | A chosen slug per page | A slug is prose: `redundancy-consultation` in a plaintext column, joined to events through `bookings.page_id`, says what those events ARE. The one rule forbids that. The handle is "who is on the team", which AGENTS.md already gives up. (Finding 1.) | |
| -| B5 | **Booking mail is the ONE outbound to a non-team address, and only to an address the recipient typed in order to receive it** | Emailing quick-add attendees "while we're at it" | Quick-add attendees stay recorded-not-invited. This is a different act: the booker asked for the mail. | |
| -| B6 | **The ICS is a download at a tokenised URL, not an attachment, and carries NO management link** | An attachment; `URL:` pointing at the manage page | `mail.Sender` is plain text. And a management capability inside an ICS is exported to wherever that calendar syncs — a shared team view, a phone, a second export. Delegating write authority is not the same as disclosing contents. (Finding 6.) | |
| -| B7 | **The availability text has ONE parser and it is Go** | A JS parser for the live pane | The standing rule. The pane is a fetch to the server, exactly as quick add. | |
| -| B8 | **A held slot is a tentative EVENT**, not a row in a side table | A holds table consulted by the slot query | The range query already knows how to subtract events. The host sees the hold on their calendar, labelled, rather than being surprised by a confirmation. | |
| -| B9 | **Slots are computed in the HOST's zone and rendered in the booker's** | Computing in the booker's zone | The windows are the host's working hours. | |
| -| B10 | **Unknown token → 404**, never 403, never "expired" | Distinguishing expired from wrong | Status codes cannot enumerate bookings. An expired hold's page says "expired" only when the token was RIGHT. | |
| -| B11 | **The EVENT's `sequence` is the one revision counter**; the booking has none | A booking-side sequence | Two counters for one meeting drift the first time the host edits it (finding 11). `UpdateEvent` already bumps `sequence`; every booking transition goes through the same column. | |
| -| B12 | **Hold creation is serialised by a process-wide mutex**, and the availability check runs inside it | Relying on the write transaction | SQLite begins deferred; two transactions both read "free" before either takes the write lock (finding 9). One binary, one process, one instance: a mutex is the honest serialisation, and a concurrent test proves it. | |
| -| B13 | **Mail is sent AFTER the row commits, and an unsent notification is a column the drain retries** | Rolling back rows when a send fails | SMTP can accept and then time out; deleting the row leaves a delivered link that 404s (finding 12). A committed row plus a `pending_mail` marker is at-least-once, like reminders. | |
| +| B3 | **Pages are per person, any number** | One page per person | "15 min chat" and "60 min call" are different pages. | |
| +| B4 | **The public identifier is a chosen plaintext `handle` plus a GENERATED page code** | A chosen slug | A slug is prose joined to events through `bookings.page_id` (r1 f1). The handle is "who is on the team", already given up. | |
| +| 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 | Quick-add attendees stay recorded-not-invited. The booker asked for the mail. | |
| +| B6 | **The ICS is a download at a tokenised URL, not an attachment, and carries NO management link** | An attachment; `URL:` | `mail.Sender` is plain text. A management capability inside an ICS syncs to wherever that calendar goes (r1 f6). | |
| +| B7 | **The availability text has ONE parser and it is Go** | A JS parser | The standing rule. | |
| +| B8 | **A held slot is a tentative EVENT** | A holds table | The range query already subtracts events; the host sees the hold. | |
| +| B9 | **Slots are computed in the HOST's zone and rendered in the booker's** | Computing in the booker's zone | The windows are the host's hours. | |
| +| B10 | **Unknown token → 404**, never 403, never "expired" | Distinguishing | Status codes cannot enumerate bookings. | |
| +| B11 | **The EVENT's `sequence` is the one revision counter**, bumped atomically (`sequence = sequence + 1`), and every booking-side conditional update names the revision it expects | A booking sequence; stale-read `existing.Sequence + 1` | Two counters drift (r1 f11); a stale read loses a concurrent bump (r2 A11); a resubmitted reschedule form must fail, not apply twice (r2 A10). | |
| +| B12 | **Hold creation and reschedule serialise on a process-wide mutex**, with the availability check inside it | The write transaction alone | SQLite begins deferred (r1 f9). One process per instance. | |
| +| B13 | **Mail is a QUEUE TABLE, one row per (booking, revision, recipient), claimed with a lease and acknowledged conditionally — the reminder queue's own pattern** | A `pending_mail` column on the booking | One mutable marker loses a cancellation that lands mid-send, lets three drains send the same mail, and cannot record that one of two recipients failed (r2 B2). | |
| +| B14 | **The token is stored SEALED as well as hashed** | Hash only, as feeds do | A queue that must resend a link needs the link. Sealed at rest satisfies the one rule; the feed's hash-only choice was about never re-showing a URL, which this feature cannot afford (r2 B1). | |
| +| B15 | **A hold expires at the EARLIER of thirty minutes and the meeting's start**, and confirmation requires `now < start` | Thirty minutes flat | A five-minute meeting held at 09:59 could be confirmed at 10:20, after it ended (r2 B5). | |
| +| B16 | **No sender error is "definite".** A failed hold mail leaves the hold in place and the queue retries; there is no `failed` state | Marking the hold failed and freeing the slot | `smtp.SendMail` can return an error AFTER the message was accepted — on `QUIT`, say — so "failed" would leave a delivered link pointing at a released slot (r2 A12). | |
| +| B17 | **A slot must be the same length on the wall clock and on the elapsed clock**; a candidate that straddles a DST transition is not offered | Round-tripping the start only | 00:30 + 60 min on the fold Sunday ends at a 01:30 the record stores as the second one, so the stored meeting is two hours long (r2 A13). | |
| --- |
| ## 2. Data |
| -All migrations additive, generated by `rastrillo migration generate`. |
| +All migrations additive, generated. |
| ### 2.1 `people.handle` |
| -``` |
| -handle TEXT UNIQUE -- nullable; a person with no handle has no public pages |
| -``` |
| - |
| -Plaintext. Lowercase `[a-z0-9-]{2,32}`, chosen on the settings page, |
| -validated on save, unique. Reserved: `m`, `new`, `static`. Changing it |
| -changes every public URL and the settings page says so before the field. |
| +Nullable, unique, plaintext, `[a-z0-9-]{2,32}`, lowercased on save. |
| +Reserved: `m`, `new`, `static`. Changing it changes every public URL |
| +and the settings page says so. |
| ### 2.2 `booking_pages` |
| @@ -77,17 +78,16 @@ availability_text_sealed, published_at (nullable), created_at, updated_at |
| UNIQUE (person_id, code) |
| ``` |
| -- `code`: eight characters from `[a-z0-9]`, generated at creation, never |
| - editable, plaintext because it is random. The public URL is |
| - `/book/{handle}/{code}`. |
| -- `title_sealed`, `description_sealed`: content. The title heads the |
| - public page and names the event on the host's calendar. |
| -- `duration_min`: 5–480. `buffer_min`: 0–120, default 10. `notice_min`: |
| - 0–10080, default 120. `horizon_days`: 1–90, default 30. |
| -- `availability_text_sealed`: the sentence, verbatim, sealed because it |
| - is prose. Re-parsed on every edit into §2.3. |
| -- `published_at`: NULL means unpublished. An unpublished page 404s |
| - publicly. Publishing is an explicit action with its own sentence |
| +- `code`: eight of `[a-z0-9]`, generated, immutable, plaintext. |
| +- `title_sealed` ≤ **100 bytes** of UTF-8; `description_sealed` ≤ 1000 |
| + bytes. Byte limits, because the event validator's limits are bytes |
| + (r2 B6). The composed event title `"{title} — {name}"` is at most 100 |
| + + 5 + 80 bytes, under the event's 200. |
| +- `duration_min` 5–480; `buffer_min` 0–120 (default 10); `notice_min` |
| + 0–10080 (default 120), **floor 10** — a slot is never offered less |
| + than ten minutes ahead (B15); `horizon_days` 1–90 (default 30). |
| +- `availability_text_sealed`: the sentence, verbatim. |
| +- `published_at`: NULL is unpublished → 404. Publishing is explicit |
| (§7.3). |
| ### 2.3 `booking_windows` |
| @@ -96,57 +96,84 @@ UNIQUE (person_id, code) |
| id, page_id (idx), weekday (0=Sunday..6), start_min, end_min |
| ``` |
| -Plaintext, deliberately: shape-of-time, already on the leak list. |
| -`0 <= start_min < end_min <= 1440`. Rewritten wholesale on every save. |
| -**Normalised before storage:** windows on one weekday that overlap or |
| -touch are merged, so `Monday 9–12, Monday 10–13` is stored as one |
| -`Monday 9–13` window and steps as one (finding 17). |
| +Plaintext (shape-of-time). `0 <= start_min < end_min <= 1440`. |
| +Rewritten wholesale on save, **merged** first: overlapping or touching |
| +windows on one weekday become one. |
| ### 2.4 `bookings` |
| ``` |
| -id, page_id (idx), event_id (idx), token_hash UNIQUE, state (idx), |
| +id, page_id (idx), event_id (idx), token_hash UNIQUE, token_sealed, state (idx), |
| booker_name_sealed, booker_address_sealed, booker_address_fp (idx), |
| note_sealed, booker_zone, |
| -hold_expires_at (idx), confirmed_at, cancelled_at, |
| -pending_mail, mail_attempts, |
| -ip_fp (idx, nullable), reschedules, |
| +hold_expires_at (idx), confirmed_at, cancelled_at, cancelled_by, |
| +ip_fp (idx, nullable), ip_forget_at (idx), reschedules, |
| created_at, updated_at |
| ``` |
| -- `token_hash`: SHA-256 of 32 random bytes, exactly `HashFeedToken`. |
| -- `state`: one of `held`, `confirmed`, `cancelled`, `expired`, `failed`. |
| - The transition table is §5.7. |
| -- `booker_name_sealed`, `booker_address_sealed`, `note_sealed`: content. |
| -- `booker_address_fp`: `seal.Fingerprint(PurposeAttendeeLookup, addr)`, |
| - keyed, as attendees. |
| -- `booker_zone`: IANA name, plaintext, and ONLY ever one of |
| - `CommonZones` (§5.1). |
| -- `pending_mail`: `''`, `hold`, `confirmed`, `rescheduled`, `cancelled` |
| - — WHICH mail is owed, never its content. `mail_attempts` counts tries; |
| - after `MaxReminderAttempts` the marker clears and the failure is |
| - logged with a stage, not an address. |
| -- `ip_fp`: `seal.Fingerprint(PurposeBookingIP, ip)` of the client IP at |
| - hold time, for the caps in §7. **Nulled by the sweep 24 hours after |
| - `created_at`**, because its purpose ends there and a permanent |
| - network-linkage column is a leak the estate does not otherwise have |
| - (finding 4). |
| -- `reschedules`: how many times this booking has been moved; capped |
| - (§7). |
| -- **No `sequence`** (B11). |
| - |
| -### 2.5 The client IP |
| - |
| -The instance listens on a unix socket that only the platform edge can |
| -reach, and the edge calls `httputil.ProxyRequest.SetXForwarded`, which |
| -REPLACES `X-Forwarded-For` with the dialling client (`platform/ |
| -internal/edge/homemap.go setForwarded`; on a verified mesh hop the home |
| -edge restores the original single-element chain). So: **the client IP |
| -is the LAST element of `X-Forwarded-For`**, and when the header is |
| -absent (tests, `rastrillo dev`) it is the host part of `RemoteAddr`. |
| -Canonicalise with `net.ParseIP(...).String()` before fingerprinting; an |
| -unparseable value is the literal string `"unknown"` and shares one |
| -bucket. Never logged. |
| +- `token_hash`: `HashFeedToken(token)`, the lookup. `token_sealed`: |
| + the same token under `PurposeBookingToken` (B14), opened only to |
| + build a mail. |
| +- `state`: `held` → `confirmed` → `cancelled`, or `held` → `expired`. |
| + Four states; §5.7 is the table. |
| +- `booker_zone`: one of `CommonZones` **or the host's own zone** — the |
| + two sources the page offers (r2 A5). Never free text. |
| +- `cancelled_by`: `''`, `booker`, `host` (r2 B2). |
| +- `ip_fp`: `Fingerprint(PurposeBookingIP, ip)`, for §7. `ip_forget_at = |
| + created_at + 24h`; the sweep nulls `ip_fp` at or after it, and |
| + `NextDueAt` includes the earliest `ip_forget_at` with a non-null |
| + `ip_fp`, so the forgetting is scheduled, not incidental (r2 A4). |
| +- `reschedules`: capped at `MaxReschedules = 5`. |
| + |
| +### 2.5 `booking_mail` |
| + |
| +``` |
| +id, booking_id (idx), kind, recipient, revision, due_at (idx), |
| +attempts, claimed_at, sent_at, created_at |
| +UNIQUE (booking_id, kind, recipient, revision) |
| +``` |
| + |
| +- `kind`: `hold`, `confirmed`, `rescheduled`, `cancelled`. |
| + `recipient`: `booker` or `host`. `revision`: the event's `sequence` |
| + at the moment the row was made. |
| +- Rows are created **inside the transition's transaction** (§5.7). |
| + `due_at = now`. The handler that made them tries to send immediately |
| + (§6); the drain owns retries. |
| +- Claimed with the reminder queue's lease (`claimed_at`, same |
| + `claimLease`), acknowledged with `UPDATE … SET sent_at WHERE id = ? |
| + AND sent_at IS NULL`. `attempts` is per row. At |
| + `MaxReminderAttempts`, `sent_at` is set to `-1` (gave up) and a log |
| + line names `stage=booking.mail code=gave_up` and nothing else. |
| +- **Superseding:** when a `cancelled` row is created, every unsent |
| + `rescheduled` row for the same booking is deleted in the same |
| + transaction. Nothing else supersedes anything: a confirmed mail is |
| + still owed after a cancel (the host should learn both). |
| +- No content. The body is regenerated from the sealed columns and the |
| + event at send time. **What a body may contain is §5.5's allowlist.** |
| + |
| +### 2.6 Ownership contract (r2 A20) |
| + |
| +Every settings read or write of a page is `WHERE id = ? AND person_id = |
| +?`. A miss is `ErrNotFound` → 404. There is no route that takes a page |
| +id without a person. `PublicBookingPage(handle, code)` is the only |
| +unscoped reader and it requires `published_at IS NOT NULL`. |
| + |
| +**Terms.** A booking's TIMES and DURATION are the event's. A page edit |
| +never touches an existing event; a reschedule keeps the event's own |
| +duration (`end - start`), not the page's current one. A booking's |
| +TITLE and DESCRIPTION as shown to the booker are the page's CURRENT |
| +ones — display text the host owns and may correct. Both facts are |
| +stated here so neither is discovered. |
| + |
| +### 2.7 The client IP |
| + |
| +The instance listens on a unix socket only the platform edge reaches; |
| +the edge calls `httputil.ProxyRequest.SetXForwarded`, which REPLACES |
| +`X-Forwarded-For` with the dialling client (`platform/internal/edge/ |
| +homemap.go setForwarded`). **The client IP is the LAST element of |
| +`X-Forwarded-For`**; absent the header, the host of `RemoteAddr`. |
| +Canonicalised with `net.ParseIP(...).String()`; unparseable → the |
| +literal `"unknown"`. Never logged. |
| --- |
| @@ -154,8 +181,9 @@ bucket. Never logged. |
| ### 3.1 What it reads |
| -One sentence, comma- or semicolon-separated clauses. Each clause is a |
| -**day part** followed by one or more **time ranges**: |
| +Clauses split on `,` and `;`. Each clause is a **day part** then one or |
| +more **time ranges**; a clause with no day part inherits the previous |
| +clause's days, and a first clause with none is a problem. |
| ``` |
| mondays between 12–3, tuesdays 12–2, 5-7 |
| @@ -165,466 +193,456 @@ every day 8–6 |
| saturday 10:30–13:00; sunday 14-16 |
| ``` |
| -- **Day part**: a weekday name in any spelling `weekdayAlt` knows, |
| - optionally plural, optionally `every`; a weekday RANGE with the quick |
| - add separators (`mon-wed`, `monday to friday`), walking forward |
| - modulo 7; `weekdays` (Mon–Fri); `weekends` (Sat, Sun); `every day` / |
| - `daily` / `all days`. |
| +- **Day part**: a weekday in any `weekdayAlt` spelling, optionally |
| + plural, optionally `every`; a weekday range (`mon-wed`, `monday to |
| + friday`) walking forward modulo 7; `weekdays`; `weekends`; `every |
| + day` / `daily` / `all days`. |
| - **Time range**: two clocks joined by `-`, `–`, `—`, `to`, `until`, |
| - `till`. Each clock is `H`, `H:MM`, `Ham`, `H:MMpm`. **Raw components |
| - are validated BEFORE any shared helper runs**: hour `0..24`, minute |
| - `0..59`, and `24` only as `24:00` at the END of a range. The quick-add |
| - helpers clamp and forgive; here nothing is forgiven (finding 17). |
| - Meridiem: an explicit `am`/`pm` on either end applies to both unless |
| - the other says otherwise; with no meridiem anywhere the working-day |
| - rule applies (a bare 1–7 is afternoon; 8–12 as written), so `5-7` is |
| - 17:00–19:00 and `9-5` is 09:00–17:00. A range whose end is not after |
| - its start is **refused with its text quoted**: a booking window does |
| - not cross midnight. |
| -- **A clause with no day part inherits the previous clause's days.** |
| - `tuesdays 12–2, 5-7` is two windows on Tuesday. A first clause with |
| - no day part is a problem. |
| + `till`. A clock is `H`, `H:MM`, `Ham`, `H:MMpm`. **Raw components are |
| + validated first, with no clamping** (r1 f17): minute `0..59`; with a |
| + meridiem the hour is `1..12` (`13pm` is a problem — r2 A17); without, |
| + `0..24`, and `24` only as `24:00` at the END of a range. **Meridiem:** |
| + an explicit `am`/`pm` on either end applies to both unless the other |
| + says otherwise; with none on either end, the working-day rule applies |
| + **to the hour component regardless of minutes**: a bare hour 1–7 is |
| + afternoon (`5-7` → 17:00–19:00; `12:30-2:30` → 12:30–14:30), 8–12 as |
| + written, 0 and 13–24 as written. End not after start → problem, the |
| + range quoted: windows do not cross midnight. |
| - Filler dropped: `between`, `from`, `and`, `on`, `at`. |
| ### 3.2 What it refuses |
| -Every token not consumed by a day part, a time range or a filler word |
| -is a problem, quoted. Every problem in the sentence is reported, not |
| -just the first. The page cannot be saved while any exists. |
| +Every unconsumed token is a problem, quoted. All problems are reported. |
| +The page cannot be saved while any exists. |
| ### 3.3 Where it lives |
| -`internal/calendar/avail.go`, `ParseAvailability(text) (windows |
| -[]Window, problems []string)`, pure. Shares `consumed`, `weekdayAlt`, |
| -`weekdays`, `rangeSep` with quick add. Does NOT call `clock` or |
| -`applyMeridiem` on unvalidated input. |
| +`internal/calendar/avail.go` + `avail_windows.go`. `ParseAvailability(text) |
| +(windows []Window, problems []string)`, pure, windows merged and sorted. |
| +Shares `consumed`, `weekdayAlt`, `weekdays`, `rangeSep` with quick add; |
| +does NOT call `clock`/`applyMeridiem`. |
| ### 3.4 The pane |
| -`POST /settings/booking/parse` returns a fragment listing merged windows |
| -in words — "Monday 12:00–15:00 · Tuesday 12:00–14:00, 17:00–19:00" — |
| -and every problem. `booking.js` is a debounced fetch cloned from |
| -`quickadd.js`. Without script, saving renders the same fragment inline |
| -at 422 with the form re-filled. |
| +`POST /settings/booking/parse` → fragment: merged windows in words plus |
| +every problem. `booking.js` is a debounced fetch cloned from |
| +`quickadd.js`. Scriptless: a save with problems re-renders at 422 with |
| +the same fragment inline. |
| --- |
| ## 4. Slots |
| -`ComputeSlots(rule, busy, now, from, to)` is pure, in |
| -`internal/calendar/slots.go`. `FreeSlots` in `store_slots.go` fetches |
| -busy and calls it. |
| +`ComputeSlots(rule, busy, now, from, to)` is pure (`slots.go`); |
| +`FreeSlots` fetches busy and calls it (`store_slots.go`). |
| -1. **Bounds.** `earliest = now + notice_min`; `latest = |
| +1. **Bounds.** `earliest = now + max(notice_min, 10m)`; `latest = |
| now.In(hostLoc).AddDate(0,0,horizon_days)`. A candidate is offered |
| - only if `start >= earliest`, `start >= from`, `end <= to` and `end |
| - <= latest`. Each candidate is tested individually; clamping the |
| - iteration range is an optimisation, never the filter (finding 16). |
| -2. **Days.** Iterate host-zone calendar days from `from` to `to` by |
| - `AddDate(0,0,1)` on a local midnight, which is the next local |
| - midnight whatever the day's length. |
| -3. **Candidates.** For each window on that weekday, for `m` from |
| - `start_min` stepping `duration_min` while `m + duration_min <= |
| - end_min`: `start = time.Date(y, mo, d, 0, m, 0, 0, hostLoc)`. |
| - **Round-trip rule:** the candidate is dropped unless |
| - `FormatLocal(start) → ToUTC` gives back `start.Unix()` AND |
| - `start.Hour()*60+start.Minute() == m`. That drops the spring gap |
| - (the wall clock does not exist) and, on the fold, keeps exactly the |
| - occurrence `ToUTC` would store — the second one, per `clock.go` — |
| - so what is offered is what the record can hold (finding 13). `end = |
| - start + duration` as ELAPSED time from the instant. |
| -4. **Busy.** Fetched once over `[from - 1 day, to + 1 day)` so a buffer |
| - at the edge sees its neighbour (finding 15). From `EventsInRange(host)` |
| - using `OccStartUTC`/`OccEndUTC`, keep `Busy(kind) && status != |
| - cancelled`; **tentative is busy**; the event being rescheduled is |
| - excluded by id; **a tentative event whose booking is `held` with |
| - `hold_expires_at <= now` is NOT busy** (a join, so an unswept hold |
| - frees its slot the moment it lapses — finding 19). **All-day |
| - events** are re-derived: their `StartLocal`/`EndLocal` dates through |
| - `DayBounds` in the HOST's zone, not their stored UTC index, which is |
| - midnight in the event's own (possibly empty) zone (finding 14). |
| - All-day NOTES are not busy, like every note. |
| -5. **Buffer.** Drop a candidate if `[start - buffer, end + buffer)` |
| - overlaps any busy interval. Half-open. |
| -6. Return ascending `Slot{StartUTC, EndUTC, Local}`; the page groups |
| - them by the BOOKER-zone date for display (finding 16), and the POST |
| - carries the instant, never a date. |
| - |
| -Tests pin the spring-forward and fold Sundays for `Europe/Dublin` 2026 |
| -with windows that TOUCH the transition (00:00–04:00), to real unix |
| -numbers, as `clock_test.go` does. |
| + iff `start >= earliest && start >= from && start < to && end <= |
| + latest`. **`to` bounds the START, not the end** (r2 A16): a slot |
| + that crosses the booker's midnight belongs to the day it starts on |
| + and is shown there. Every inequality is tested per candidate. |
| +2. **Days.** Host-zone calendar days from `from` to `to`, stepping by |
| + `AddDate(0,0,1)` on a local midnight. |
| +3. **Candidates.** For each window that weekday, `m` from `start_min` |
| + by `duration_min` while `m + duration_min <= end_min`: `start = |
| + time.Date(y, mo, d, 0, m, 0, 0, hostLoc)`, `end = start + duration` |
| + (elapsed). **Kept only if all four hold** (B17): `start`'s wall |
| + minute is `m`; `end`'s wall minute is `m + duration_min` on the same |
| + local date (or `1440` = next midnight); `ToUTC(FormatLocal(start)) |
| + == start.Unix()`; `ToUTC(FormatLocal(end)) == end.Unix()`. A slot |
| + that straddles the spring gap or the autumn fold fails one of these |
| + and is not offered. What is offered is exactly what the record can |
| + hold, both ends. |
| +4. **Busy.** Fetched once over `[from - 1 day, to + 1 day)` (r1 f15). |
| + From `EventsInRange(host)` — **after the fix in §4.1** — using |
| + `OccStartUTC`/`OccEndUTC`: keep `Busy(kind) && status != cancelled`; |
| + tentative is busy; the event being rescheduled is excluded by id; a |
| + tentative event whose booking is `held` with `hold_expires_at <= now` |
| + is NOT busy (join). **All-day** events: take the occurrence's DATE |
| + as `time.Unix(OccStartUTC).In(eventZoneOrUTC).Format(date)` through |
| + `time.Unix(OccEndUTC-1)…` — the Occ instants are midnights in the |
| + event's own zone, so this recovers the occurrence's dates, not the |
| + master's (r2 A14) — then `DayBounds` of each date in the HOST's |
| + zone. All-day notes are not busy. |
| +5. **Buffer.** Drop if `[start - buffer, end + buffer)` overlaps busy. |
| +6. Return ascending `Slot{StartUTC, EndUTC, Local}`; the page groups by |
| + the booker-zone date of START. |
| + |
| +### 4.1 A fix to the range query it depends on (r2 B3) |
| + |
| +`EventsInRange` today drops an override whose master the query did not |
| +select — a series that ended on 1 September with its last occurrence |
| +moved to 20 September is invisible on 20 September, in every view, not |
| +just here. Fix: override rows returned by `rangeRows` contribute their |
| +`SeriesID` to the set `overridesFor` loads, so pass 2 places them. A |
| +regression test in `occurrence_test.go` covers the finite-series case. |
| +Busy time is then what the calendar shows. |
| + |
| +### 4.2 Cost (r2 B7) |
| + |
| +`?day=` outside `[today, today + horizon_days]` in the booker's zone → |
| +404. The day list is computed from ONE `EventsInRange` over the |
| +horizon; a day page from ONE over that day ±1. And every public route, |
| +GET and POST, passes an in-process per-IP token bucket: 60 requests per |
| +minute per `ip_fp`, 429 beyond it. It is memory, resets on restart, and |
| +is the cheap gate in front of the expensive work; §7's row-based caps |
| +are the durable one behind it. |
| --- |
| ## 5. The public flow |
| -All public routes sit in the chi router beside `/feed/{token}.ics`: |
| -inside `csrf.Protect`, outside the signed-in group. GETs pass the origin |
| -check; POSTs from our own page carry `Sec-Fetch-Site: same-origin`. |
| -CSRF is not the abuse control — a script can send that header; §7 is. |
| - |
| -Every public response sets `Cache-Control: no-store` and |
| -`Referrer-Policy: no-referrer` (the token is in the path). The request |
| -path with the token WILL appear in rastrillo's access log; that is the |
| -same residual the feed URL has and is stated here rather than hidden. |
| +Public routes sit beside `/feed/{token}.ics`: inside `csrf.Protect`, |
| +outside the session group. Every public response sets `Cache-Control: |
| +no-store` and `Referrer-Policy: no-referrer`. The token is in the path |
| +and WILL be in the access log — the feed's own residual, stated. |
| ### 5.1 `GET /book/{handle}/{code}` |
| -`PublicBookingPage(handle, code)` → 404 for an unknown handle, unknown |
| -code or unpublished page, byte-identical to the router's own 404. The |
| -page: title, description, duration, the host's first name (opened |
| -`NameSealed`) or handle, and a zone `<select>` whose options are |
| -**exactly `CommonZones`** — `?zone=` outside that set is ignored and |
| -the host's zone used. This bounds the input to `Zone()` (finding 5; the |
| -cache is also made concurrency-safe, §9). |
| +`PublicBookingPage` → 404 byte-identical to the router's own. The page: |
| +title, description, duration, host's first name (opened `NameSealed`) |
| +or handle, a zone `<select>` of `CommonZones` **plus the host's zone if |
| +it is not among them**; `?zone=` outside that set → the host's zone. |
| No `?day=`: the next days with any free slot, as links carrying `day` |
| -(a BOOKER-zone date) and `zone`. With `day`: that day's slots in the |
| -booker's zone, each a `<form method=post>` with hidden `start` (UTC |
| -unix) and `zone` and a button. Pressing one posts to the same URL |
| -**without `name`**, and the handler renders the page again with that |
| -slot fixed at the top and the details form — `name`, `address`, `note` |
| -— beneath it, still carrying `start` and `zone` hidden. Two POSTs, no |
| -script. `booking.js` may only preselect the zone. |
| +(booker-zone date) and `zone`. With a valid `day`: that day's slots as |
| +`<form method=post>`s carrying hidden `start` (UTC unix) and `zone`. |
| +Pressing one posts WITHOUT `name`; the handler verifies the slot is |
| +still free and renders the page with that slot fixed and the details |
| +form (`name`, `address`, `note`) beneath, `start` and `zone` hidden. |
| +Two POSTs, no script. `booking.js` may only preselect the zone. |
| ### 5.2 `POST /book/{handle}/{code}` with `name` — hold |
| -Limits: `name` 1–100 runes; `address` must match `reAddress` in full |
| -and normalise to one address; `note` ≤ 2000 runes; body ≤ 16 KiB |
| -(`http.MaxBytesReader`). Anything over → 422 with the form. |
| - |
| -Then, **inside `holdMu` (B12)**: |
| -1. §7 caps. Over → 429. |
| -2. `FreeSlots` for the slot's own day; `start` must be one of them |
| - exactly, or 409 "That time has just gone" with the day's remaining |
| - slots. |
| -3. One transaction: the event on the host's personal calendar — title |
| - `"{page title} — {name}"`, notes the booker's note, kind meeting, |
| - **status tentative**, TZ the host's zone, `start_local`/`end_local` |
| - from the instant in that zone; and the booking row, `held`, |
| - `hold_expires_at = now + 30m`, `pending_mail = 'hold'`, token minted |
| - and hashed. |
| - |
| -Then, outside the mutex: send the hold mail (§6). Success → clear |
| -`pending_mail`. **Definite** failure (the sender returned an error) → |
| -state `failed`, event soft-deleted, 422 "We could not send to that |
| -address"; the row stays and counts toward §7. Otherwise the response is |
| -200 "Check your mail" with the address echoed and the expiry in the |
| -booker's zone. |
| +Body ≤ 16 KiB (`MaxBytesReader`). `name` 1–60 runes AND ≤ 80 bytes; |
| +`address` a full `reAddress` match normalising to one address; `note` |
| +≤ 1000 runes AND ≤ 4000 bytes (r2 B6). Over → 422 with the form. |
| + |
| +Inside `holdMu` (B12): §7 caps → 429; `FreeSlots` for that day, `start` |
| +must be present → else 409 "That time has just gone"; then ONE |
| +transaction: the tentative event (title `"{title} — {name}"`, notes = |
| +the note, kind meeting, TZ host zone, local strings from the instant), |
| +the booking (`held`, `hold_expires_at = min(now + 30m, start)`, token |
| +hashed AND sealed, `ip_fp`, `ip_forget_at`), and a `booking_mail` row |
| +(`hold`, `booker`, revision 0). |
| + |
| +Outside the mutex: try to send that row now (§6). **Whatever happens, |
| +the hold stands** (B16). Response 200 "Check your mail" with the |
| +address and the expiry in the booker's zone; if the send returned an |
| +error the page adds "If nothing arrives in a few minutes, the hold |
| +lapses at {expiry} and you can try again." |
| ### 5.3 `GET /book/m/{token}` |
| -`BookingByToken` → 404. Then by state: |
| +`BookingByToken` → 404. Reads the EVENT for status and deletion. Then: |
| -- `held`, `now < hold_expires_at`: a page saying what is being booked |
| - and when (booker zone), with ONE form: `POST /book/m/{token}/confirm`, |
| - button "Confirm this booking". Below it: "Didn't ask for this? Do |
| - nothing; it expires at …". **Nothing changes on this GET.** (B1.) |
| -- `held`, expired, or `expired`: "This hold expired at …", link to the |
| - page. |
| -- `confirmed`: the manage page (§5.5). |
| -- `cancelled`, `failed`: says so, link to the page. |
| +- `held` and `now < hold_expires_at`: what and when; ONE form → `POST |
| + …/confirm`, button "Confirm this booking"; "Didn't ask for this? Do |
| + nothing; it expires at …". Nothing changes on GET. |
| +- `held` past expiry, or `expired`: "This hold expired at …", link |
| + back. |
| +- `confirmed` and the event is not cancelled or deleted: manage (§5.5). |
| +- otherwise (`cancelled`, or the host cancelled/deleted the event): |
| + "cancelled", who by, link back. |
| ### 5.4 `POST /book/m/{token}/confirm` |
| -Transition `held → confirmed` per §5.7, conditional on `state = 'held' |
| -AND hold_expires_at > now`, all in one transaction: booking state and |
| -`confirmed_at`; event `status = confirmed`, `sequence + 1`; attendee |
| -row (`SetAttendees`); the host's default reminder (`EnsureDefaultReminder` |
| -— finding 18); `pending_mail = 'confirmed'`. Zero rows affected → the |
| -state has moved; re-render §5.3 for whatever it now is. Then send the |
| -host mail (§6) and clear the marker. Pressing twice cannot mail twice: |
| -the second press affects zero rows. |
| - |
| -### 5.5 The manage page (state `confirmed`) |
| - |
| -What (page title), when (booker zone, and host zone when they differ), |
| -who (host name/handle and host address), and: |
| -- **Add to calendar** → `GET /book/m/{token}/calendar.ics` |
| -- **Reschedule** → `GET /book/m/{token}/reschedule` |
| -- **Cancel** → a form, `POST /book/m/{token}/cancel` |
| - |
| -**The public allowlist (finding 7):** every public HTML page, every |
| -mail to the booker and the ICS may show ONLY: page title, page |
| -description, the slot's times, the host's name/handle and address, the |
| -booker's own name and address, and the booking's state. **Never** the |
| -event's notes, location, or attendee list, all of which the host may |
| -have edited since. A test edits the event as the host after |
| -confirmation and asserts the additions never reach the booker. |
| - |
| -**Host authority (finding 11):** the manage page reads the EVENT. If |
| -the host has set the event cancelled or deleted it, the manage page |
| -shows cancelled and offers no reschedule; the ICS says `METHOD:CANCEL`. |
| -`UpdateEvent` refuses an `RRule` on an event that a booking references |
| -("This is a booked meeting; it cannot repeat."), and `DeleteEvent` on |
| -one sets the booking `cancelled` in the same transaction with |
| -`pending_mail = 'cancelled'` so the booker IS told when the host |
| -deletes — the cheapest honest version of "the host is not a silent |
| -authority". |
| +One transaction: `UPDATE bookings SET state='confirmed', confirmed_at |
| +WHERE id=? AND state='held' AND hold_expires_at > ? AND ? < event |
| +start` — the third guard read from the event row in the same |
| +transaction; `RowsAffected == 1` or stop and re-render §5.3. Then event |
| +`status='confirmed', sequence = sequence + 1`; attendee row; the |
| +host's default reminder; `booking_mail(confirmed, host, revision)`. |
| +Then try to send. A second press affects zero rows and does nothing. |
| + |
| +### 5.5 The manage page |
| + |
| +What (page title, description), when (booker zone; host zone if it |
| +differs), who (host name/handle, host address), and: **Add to |
| +calendar** (`…/calendar.ics`), **Reschedule**, **Cancel** (a form). |
| + |
| +**The public allowlist (r1 f7):** every public page, booker mail and |
| +the ICS may contain ONLY: page title, page description, the event's |
| +start and end, the event's status, the host's name/handle and address, |
| +the booker's own name and address, `cancelled_by`. **Never** the |
| +event's notes, location or attendee list. A test edits all three as the |
| +host after confirmation and asserts none reaches manage, ICS or mail. |
| + |
| +**Host authority (r1 f11, r2 A11, A18).** The host edits the event like |
| +any event, and the booking follows the event: |
| +- `UpdateEvent` refuses an `RRule` on an event a booking references: |
| + `ValidationError{"repeat", "This is a booked meeting; it cannot |
| + repeat."}`. |
| +- `UpdateEvent` that sets `status = cancelled` on a booked event, in |
| + the same transaction: booking `confirmed → cancelled`, `cancelled_by |
| + = host`, reminder queue rows for the event deleted, `booking_mail |
| + (cancelled, booker)`. A `held` booking on a host-cancelled event → |
| + `expired`. |
| +- `DeleteEvent` on a booked event: the same transitions, and `sequence |
| + = sequence + 1` on the (soft-deleted) event so the CANCEL ICS carries |
| + a higher revision than the REQUEST did. |
| +- `UpdateEvent`'s `sequence` bump becomes `gorm.Expr("sequence + 1")` |
| + everywhere; the stale `existing.Sequence + 1` goes. |
| +- The reminder claim path drops a queue row whose event is `cancelled` |
| + (today it checks only `deleted_at`). |
| ### 5.6 Reschedule and cancel |
| -`GET …/reschedule`: the picker from §5.1 with the booking's own event |
| -excluded from busy, posting to `POST …/reschedule` with `start`, `zone`. |
| -Inside `holdMu`: `reschedules < MaxReschedules` (§7), `FreeSlots` check, |
| -then one transaction: event `start_local`/`end_local`/UTC index through |
| -the same explicit column allowlist `UpdateEvent` uses, `sequence + 1`; |
| -`RescheduleReminder` for every reminder on the event; booking |
| -`reschedules + 1`, `pending_mail = 'rescheduled'`. Then mail both |
| -parties. |
| - |
| -`POST …/cancel`: conditional on `state = 'confirmed'`: event `status = |
| -cancelled`, `sequence + 1`; reminder queue rows for the event deleted; |
| -booking `cancelled`, `cancelled_at`, `pending_mail = 'cancelled'`. Mail |
| -both. A second press affects zero rows and mails nobody. |
| +`GET …/reschedule`: the §5.1 picker with the booking's event excluded |
| +from busy, and a hidden `rev` = the event's current `sequence`. `POST |
| +…/reschedule` with `start`, `zone`, `rev`. Inside `holdMu`: state |
| +`confirmed`; `reschedules < MaxReschedules` (else 429); `FreeSlots` |
| +check with the EVENT's duration; then one transaction: `UPDATE events |
| +SET start_local, end_local, start_utc, end_utc, sequence = sequence + |
| +1, updated_at WHERE id = ? AND sequence = ?` (the `rev` — a resubmitted |
| +form affects zero rows and re-renders, r2 A10); booking `reschedules = |
| +reschedules + 1`; `RescheduleReminder` per reminder; two `booking_mail` |
| +rows (`rescheduled`, booker + host, new revision). Then try both. |
| + |
| +`POST …/cancel`: `UPDATE bookings SET state='cancelled', cancelled_at, |
| +cancelled_by='booker' WHERE id=? AND state='confirmed'`; event |
| +`status='cancelled', sequence = sequence + 1`; reminder queue rows |
| +deleted; unsent `rescheduled` mail rows deleted; two `booking_mail` |
| +rows (`cancelled`). A second press affects zero rows. |
| ### 5.7 Transitions |
| -| From | Event | To | Guard | Side effects (same transaction) | |
| +| From | Trigger | To | Guard (in the UPDATE) | Same transaction | |
| |---|---|---|---|---| |
| -| — | hold | `held` | caps; slot free; inside `holdMu` | tentative event; `pending_mail='hold'` | |
| -| `held` | send failed | `failed` | definite sender error | event soft-deleted | |
| -| `held` | confirm POST | `confirmed` | `hold_expires_at > now` | event confirmed, seq+1; attendee; default reminder; `pending_mail='confirmed'` | |
| -| `held` | sweep | `expired` | `hold_expires_at <= now` | event soft-deleted | |
| -| `confirmed` | reschedule | `confirmed` | slot free; `reschedules < max`; inside `holdMu` | event moved, seq+1; reminders rescheduled; `pending_mail='rescheduled'` | |
| -| `confirmed` | cancel (booker) | `cancelled` | — | event cancelled, seq+1; queue rows deleted; `pending_mail='cancelled'` | |
| -| `confirmed` | host deletes event | `cancelled` | — | `pending_mail='cancelled'` | |
| -| any | sweep, 24h after created | same | — | `ip_fp = NULL` | |
| - |
| -Every transition is a conditional `UPDATE … WHERE id = ? AND state = ?` |
| -(plus the guard) and checks `RowsAffected == 1`. Zero rows means |
| -somebody else moved it first; the caller re-reads and renders, never |
| -retries the transition. No transition sends mail inside its |
| -transaction; the marker does. |
| +| — | hold | `held` | caps; slot free; `holdMu` | tentative event; `mail(hold, booker, 0)` | |
| +| `held` | confirm POST | `confirmed` | `hold_expires_at > now AND now < event.start_utc` | event confirmed, seq+1; attendee; default reminder; `mail(confirmed, host, seq)` | |
| +| `held` | sweep | `expired` | `hold_expires_at <= now` | event soft-deleted; queue rows deleted | |
| +| `held` | host cancels/deletes event | `expired` | — | (event already handled by the host's call) | |
| +| `confirmed` | reschedule | `confirmed` | slot free; `reschedules < 5`; `holdMu`; `events.sequence = rev` | event moved, seq+1; reminders rescheduled; `reschedules+1`; `mail(rescheduled, both, seq)` | |
| +| `confirmed` | booker cancels | `cancelled` | `state='confirmed'` | event cancelled, seq+1; queue rows deleted; unsent `rescheduled` mail deleted; `cancelled_by=booker`; `mail(cancelled, both, seq)` | |
| +| `confirmed` | host cancels/deletes event | `cancelled` | `state='confirmed'` | seq+1; queue rows deleted; `cancelled_by=host`; `mail(cancelled, booker, seq)` | |
| +| any | sweep at `ip_forget_at` | same | `ip_fp IS NOT NULL` | `ip_fp = NULL` | |
| + |
| +Every transition is a conditional `UPDATE` checking `RowsAffected == |
| +1`; zero rows → re-read and render, never retry. No mail is sent inside |
| +a transaction; the row is. |
| --- |
| ## 6. Mail |
| -Plain text through `mail.Sender`, which has one configured sender and |
| -no per-message From: the host's address is in the BODY (and, for the |
| -booker, in the ICS as ORGANIZER), never claimed as the envelope sender |
| -(finding 21). Bodies are `text/template` files in `templates/mail/`. |
| -No booker content in any log line. |
| +Plain text through `mail.Sender`, one configured sender, no per-message |
| +From: the host's address is in the BODY (and as `ORGANIZER` in the |
| +ICS). Bodies are `text/template` files in `templates/mail/`, rendered |
| +at send time from the booking, the page, the event (allowlisted fields |
| +only) and the opened token. No booker content in any log line. |
| -| `pending_mail` | To | Says | |
| +| kind | to | Says | |
| |---|---|---| |
| | `hold` | booker | "Somebody — we hope you — asked to book {title} with {host} on {when, booker zone}. If that was you, confirm it here: {link}. If not, do nothing; it expires at {expiry}." | |
| | `confirmed` | host | "{name} ({address}) booked {title}, {when, host zone}. Note: {note}. It is on your calendar: {event link}." | |
| -| `rescheduled` | booker AND host | The new time in each recipient's zone; the manage link (booker) or event link (host). "If you added this to a calendar, download it again — the file does not update itself." | |
| -| `cancelled` | booker AND host | What was cancelled, when it had been, who cancelled (booker or host). | |
| - |
| -**Delivery (B13):** after the transition commits, the handler sends |
| -once. Success clears `pending_mail`. Failure leaves it and bumps |
| -`mail_attempts`; the drain (§8) retries any row with a marker, |
| -regenerating the body from the sealed columns, up to |
| -`MaxReminderAttempts`, then clears it and logs `stage=booking.mail |
| -code=gave_up`. The hold mail is the one exception: a definite failure |
| -there moves the row to `failed` (§5.2), because a hold nobody can |
| -confirm is a stolen slot. An ambiguous failure — the sender returned |
| -success but the mail never arrived — is not detectable and the hold |
| -simply expires. |
| - |
| -The `mail.Sender` ignores its context. Wrap every send in |
| -`context.WithTimeout(ctx, 20*time.Second)` anyway so a future sender |
| -that honours it is bounded, and say in a comment that today's does not. |
| +| `rescheduled` | booker, host | The new time in the recipient's zone; the manage link (booker) or event link (host). "If you added this to a calendar, download it again — the file does not update itself." | |
| +| `cancelled` | booker, host | What, when it had been, and `cancelled_by` in words. | |
| + |
| +**Sending.** `sendBookingMail(row)` renders and calls a `timedSender` |
| +that runs `Send` in a goroutine and waits at most 20 seconds, returning |
| +`ErrMailTimeout` and abandoning the goroutine otherwise — the only way |
| +to bound a sender that ignores its context, and the abandoned send is |
| +still a send, so at-least-once holds (r2 B4). Success → `sent_at`. |
| +Error → `attempts + 1`, `claimed_at` refreshed so the lease expires |
| +and the drain retries. Nothing is ever concluded from an error except |
| +"try again later" (B16). |
| --- |
| ## 7. Abuse |
| -None of these needs a dependency. All are counted from `bookings` rows, |
| -which persist through every state including `failed` — that is why |
| -failures are rows, not rollbacks (finding 3). |
| - |
| -### 7.1 Caps, checked before any slot arithmetic |
| +### 7.1 Durable caps, from `bookings` rows, checked inside `holdMu` before slot arithmetic |
| | Cap | Key | Window | Limit | |
| |---|---|---|---| |
| | Open holds | `ip_fp` | `state='held' AND hold_expires_at > now` | 5 | |
| | Open holds | `page_id` | same | 20 | |
| | Open holds | `booker_address_fp` | same | 2 | |
| -| Attempts | `ip_fp` | `created_at > now - 24h`, any state | 20 | |
| -| Attempts | `booker_address_fp` | `created_at > now - 24h`, any state | 6 | |
| -| Reschedules | booking | lifetime | `MaxReschedules = 5` | |
| - |
| -Over any cap: 429, "Too many bookings in progress. Try again later." |
| -The `ip_fp` caps stop applying to a row when the sweep nulls its |
| -`ip_fp` at 24 hours, which is exactly when the attempt window closes. |
| - |
| -### 7.2 What this does not stop |
| - |
| -A patient adversary with many mailboxes and many addresses can still |
| -book real slots one at a time, confirm them, and waste the host's |
| -time. So can a patient human with a phone. The host cancels them like |
| -any other event and, if it continues, unpublishes the page. Nothing |
| -here claims to stop a determined person; it stops a script from |
| -emptying a calendar or sending mail in a loop. |
| - |
| -### 7.3 The oracle, stated as new |
| - |
| -A published page lets **anyone on the internet** ask when this host is |
| -free, without holding the database. The existing leak list is about |
| -what a published FILE gives up; this is a live query, and because every |
| -granted calendar counts it also reflects the team calendar's shape |
| -(finding 8). So publishing is a deliberate act: the toggle is labelled |
| -**Publish**, and the sentence above it reads "Anyone with this link can |
| -see when you are free — not what you are doing, but when — including |
| -time blocked by team calendars you can see." AGENTS.md's leak list |
| -gains this as item 5, worded as a new disclosure, not an existing one. |
| +| Attempts | `ip_fp` | `created_at > now - 24h` | 20 | |
| +| Attempts | `booker_address_fp` | `created_at > now - 24h` | 6 | |
| +| Reschedules | booking | lifetime | 5 | |
| + |
| +Over → 429 "Too many bookings in progress. Try again later." |
| + |
| +### 7.2 The cheap gate (r2 B7) |
| + |
| +An in-process token bucket per `ip_fp`, 60 requests/minute, on every |
| +public route including GETs and the name-less POST. 429. Memory only. |
| + |
| +### 7.3 What this does not stop |
| + |
| +A patient adversary with many mailboxes and addresses books real slots |
| +one at a time. So does a patient human. The host cancels them and, if |
| +it continues, unpublishes. Nothing here stops a determined person; it |
| +stops a script. |
| + |
| +### 7.4 The oracle, stated as new (r1 f8) |
| + |
| +A published page lets anyone ask when this host is free, without the |
| +database, and every granted calendar shapes the answer. Publishing is a |
| +deliberate act: the button reads **Publish** and the sentence above it |
| +reads "Anyone with this link can see when you are free — not what you |
| +are doing, but when — including time blocked by team calendars you can |
| +see." AGENTS.md's leak list gains this as item 5. |
| --- |
| -## 8. The sweep |
| +## 8. The sweep (r2 A4, A19, B4) |
| + |
| +`drain` (the tick) runs, in order, inside the existing 45-second |
| +`drainDeadline` shared by everything: |
| -`drain` (tick and `DrainOnRequest`) runs three bounded steps, each |
| -independent of whether the others found work (finding 19): |
| +1. **Expire holds** (cheap): `held AND hold_expires_at <= now` → |
| + `expired`, event soft-deleted, queue rows deleted. ≤ 200. |
| +2. **Reminders**, exactly as today, until `deadline/2` or the queue is |
| + empty — so reminders make progress whatever the mail queue holds. |
| +3. **Booking mail**: claim rows `sent_at IS NULL AND due_at <= now` |
| + with an expired lease, oldest first, until the deadline; each send |
| + bounded by `timedSender`. |
| +4. **Forget IPs**: `ip_fp IS NOT NULL AND ip_forget_at <= now` → NULL. |
| + ≤ 500. |
| -1. **Expire holds**: `held AND hold_expires_at <= now` → `expired`, |
| - event soft-deleted, reminder queue rows for it deleted. Bounded to |
| - 200 per pass. |
| -2. **Owed mail**: up to 20 rows with `pending_mail <> ''`, oldest |
| - first; send, clear or bump `mail_attempts`. |
| -3. **Forget IPs**: `ip_fp IS NOT NULL AND created_at <= now - 24h` → |
| - `NULL`. Bounded to 500. |
| -4. Then the reminder loop as today. |
| +`DrainOnRequest` (a signed-in page view) does ONE thing: a due |
| +reminder if any, else one booking mail, else one expiry. Never more. |
| -`NextDueAt` returns the EARLIEST of: the next reminder `due_at`; the |
| -next `hold_expires_at` among `held` rows; `now` if any `pending_mail` |
| -is set. It is what the platform wakes the instance for, so a pending |
| -mail wakes it now and a hold wakes it at expiry. |
| +`NextDueAt` = the earliest of: next reminder `due_at`; next `held` |
| +`hold_expires_at`; next `booking_mail.due_at` with `sent_at IS NULL`; |
| +next `ip_forget_at` with `ip_fp IS NOT NULL`. |
| --- |
| ## 9. Testing |
| -Enforced, not remembered. |
| - |
| -- **Parser** (`avail_test.go`): every example in §3.1; inheritance; a |
| - first clause with no day; midnight refusal; `25:00`, `9:60`, `24:00` |
| - at start vs end; plural/singular; `every`; `weekdays`/`weekends`; |
| - each separator; the en dash; three ranges; two problems both |
| - reported; overlapping windows merged. |
| -- **Slots** (`slots_test.go`): the 12–3 / 60-min arithmetic with and |
| - without a 10-min buffer (12:00 and 13:00 go, 14:00 stays); tentative |
| - blocks; a lapsed-but-unswept hold does not; cancelled does not; a note |
| - does not; an all-day commitment blocks the HOST's whole day even when |
| - the host is in `Asia/Tokyo` and the event's zone is empty; notice |
| - hides the near future per-candidate; horizon ends the list; a busy |
| - block ending five minutes before `from` still removes the first slot |
| - when buffered; the spring-forward and fold Sundays with a 00:00–04:00 |
| - window pinned to real unix numbers; a Tokyo Monday slot displays on a |
| - Los Angeles Sunday and books the right instant. |
| -- **Concurrency**: 20 goroutines hold the same slot at once; exactly one |
| - succeeds, the rest get `ErrSlotTaken`. Run with `-race`. |
| -- **Flow** (`booking_http_test.go`): through the router with |
| - `pickyMailer`. Hold → one mail; the link's GET changes nothing (state |
| - still `held`, no host mail); the confirm POST confirms, records the |
| - attendee, creates the default reminder, mails the host once; a second |
| - POST affects nothing and mails nobody; the same slot cannot be held |
| - twice (409); wrong token 404 byte-identical to a route 404; expired |
| - says expired; the ICS has `METHOD:REQUEST`, `ORGANIZER`, `ATTENDEE |
| - …ACCEPTED`, `SEQUENCE:` equal to the event's, NO `URL:`, and parses |
| - with the app's own importer; reschedule bumps the EVENT sequence and |
| - mails both; cancel gives `METHOD:CANCEL`; **the host adds a note, |
| - a location and a second attendee after confirmation, and none of the |
| - three appears in the manage page, the ICS or any booker mail**; the |
| - host deletes the event and the booker's page shows cancelled and the |
| - booker is mailed; `UpdateEvent` refuses an RRule on a booked event. |
| -- **Mail outbox**: a refused host mail leaves `pending_mail='confirmed'`; |
| - `tick` retries it; `NextDue` is `<= now` while it is owed. |
| -- **Caps**: the sixth open hold from one IP 429s; expiry frees it; the |
| - 21st attempt in 24h 429s even though every earlier hold failed; |
| - `MaxReschedules` is enforced. |
| -- **Sweep**: `tick` expires a hold, deletes its event and reminder |
| - rows, frees the slot; nulls `ip_fp` after 24h; `NextDueAt` returns |
| - the hold's expiry when it is sooner than any reminder. |
| -- **`Zone()`**: 100 goroutines resolve 100 distinct invalid names |
| - concurrently under `-race`; the cache holds no failures afterwards. |
| -- **The one rule**: `TestTheDatabaseNeverHoldsAPlaintextTitle` gains a |
| - booking fixture — booker name, address, note, page title, description, |
| - availability text, token — grepped from the raw bytes. |
| -- **Logs**: a `slog` handler over a buffer; a failed send; the buffer |
| - has no address and no token. |
| -- **Browser** (`make browser`): a stranger with JavaScript OFF books a |
| - slot end to end, reads "check your mail", opens the recorded link, |
| - presses Confirm, sees Reschedule. With JS on, the settings pane fills |
| - in from the availability text. |
| +- **Parser**: every §3.1 example; inheritance; first clause without a |
| + day; midnight; `25-26`, `9:60`, `13pm`, `24:00` at start vs end, |
| + `12:30-2:30`; each separator; the en dash; three ranges; two problems |
| + both reported; merged and touching windows. |
| +- **Slots**: 12–3 / 60 with and without buffer; a busy block ending |
| + 5 min before `from` still removes the first slot when buffered; |
| + tentative blocks; lapsed unswept hold does not; cancelled does not; |
| + a note does not; a recurring all-day commitment blocks the OCCURRENCE |
| + date, not the master's; an all-day event with empty zone blocks the |
| + host's whole day in `Asia/Tokyo`; notice floor of 10 min; horizon |
| + bounds `end`; `to` bounds start not end (a slot crossing booker |
| + midnight is offered on its start day); spring-forward and fold |
| + Sundays with a 00:00–04:00 window: no offered slot straddles the |
| + transition, every offered start AND end round-trips through `ToUTC`, |
| + pinned unix numbers. |
| +- **Range query**: a finite series whose last occurrence was moved past |
| + `UNTIL` appears in `EventsInRange` for the moved day (new regression |
| + in `occurrence_test.go`). |
| +- **Concurrency**: 20 goroutines, one slot, one winner, `-race`. |
| +- **Flow**: hold → one mail row, one send; GET link changes nothing; |
| + confirm POST confirms, attendee, default reminder, one host mail; |
| + second POST nothing; confirm after `start` refused; 409 on a taken |
| + slot; wrong token 404 byte-identical; expired says expired; ICS |
| + `METHOD:REQUEST`, `ORGANIZER`, `ATTENDEE …ACCEPTED`, `SEQUENCE` = |
| + event's, no `URL:`, parses with `ParseICS`; reschedule with the right |
| + `rev` bumps sequence and mails both; the SAME form resubmitted (stale |
| + `rev`) affects nothing; cancel → `METHOD:CANCEL`, unsent rescheduled |
| + rows gone; host adds note/location/attendee → none reaches manage, |
| + ICS or mail; host cancels via edit → booker mailed, page says |
| + cancelled by host; host deletes → same, and the CANCEL ICS has a |
| + higher sequence; RRule refused on a booked event. |
| +- **Mail queue**: kill-before-send simulated by inserting the row and |
| + running `tick` — the link in the sent mail works (the token was |
| + recoverable); a refused host mail is retried by `tick` and gives up |
| + at `MaxReminderAttempts` with `sent_at = -1`; a hold whose send |
| + errors keeps its slot; two drains do not send one row twice (claim |
| + lease); `timedSender` returns within 20 s against a stalling sender. |
| +- **Caps and gate**: sixth open hold per IP → 429; expiry frees it; |
| + 21st attempt in 24h → 429 with none open; `MaxReschedules`; the 61st |
| + public GET in a minute → 429; `?day=` past the horizon → 404. |
| +- **Sweep**: `tick` expires, soft-deletes, frees; retries owed mail; |
| + nulls `ip_fp` at `ip_forget_at`; `NextDueAt` returns each of the four |
| + sources when it is the earliest; `DrainOnRequest` does one thing. |
| +- **`Zone()`**: 100 goroutines × garbage names under `-race`; the |
| + cache holds no failures. |
| +- **Reminders**: a cancelled event's queue row is dropped by the claim |
| + path. |
| +- **The one rule**: booker name, address, note, page title, description, |
| + availability, and the token — none in the raw bytes. |
| +- **Logs**: no address, no token, after a failed send. |
| +- **Browser**: JS off — book, "check your mail", open the link, press |
| + Confirm, see Reschedule. JS on — the settings pane fills in. |
| --- |
| -## 10. What is NOT in this version, said so nobody reads it in |
| +## 10. What is NOT in this version |
| -- **Group / round-robin pages.** |
| -- **Booker questions / custom fields.** |
| -- **Host-initiated reschedule from the manage flow.** The host edits |
| - the event like any other; the booker is told only on DELETE (§5.5). |
| - An edit that moves the time does not mail the booker, and the event |
| - page says so beside the booking badge: "Moving this here does not tell |
| - {name}. Cancel and let them rebook, or tell them yourself." |
| +- Group / round-robin pages; booker custom fields; payments; video |
| + links; reminders to the booker. |
| +- **Host-initiated reschedule that tells the booker.** A host edit that |
| + moves the time does not mail the booker; the event page says so |
| + beside the booking badge. Cancel and delete DO tell the booker (§5.5). |
| - **Automatic calendar sync.** The ICS is a download of the current |
| - state. A recipient who imported it re-imports after a change mail; the |
| - mail says so. |
| -- **Payments, video links, reminders to the booker.** |
| -- **Locale.** English strings, like the rest of the app. |
| -- **Retention.** Cancelled and expired rows stay, sealed, minus `ip_fp`. |
| + state; the change mail says to download it again. |
| +- Locale; retention (rows stay, sealed, minus `ip_fp`). |
| --- |
| ## 11. Changes to AGENTS.md in the same commit |
| -- Settled decisions: rows for B1, B2, B4, B5, B8, B11, B12, B13. |
| -- Frontend: after "Attendees are recorded, never invited": **"Except a |
| - BOOKER, who typed their address to be mailed. Booking mail is the one |
| - outbound to a non-team address."** |
| -- Data: **"Booker name, address and note are sealed; the address has |
| - the keyed fingerprint. A page's public code is generated, never a |
| - chosen slug — a slug is prose. Booking windows are plaintext: they are |
| - shape-of-time."** |
| -- Data: **"Every booking transition is a conditional UPDATE on state |
| - that checks RowsAffected. Zero rows is 'someone moved it first', never |
| - a retry."** |
| -- Reminders → renamed **Ticks**: **"`NextDue` is the earliest of the |
| - next reminder, the next hold expiry, and now-if-mail-is-owed. A new |
| - kind of scheduled work goes here, never in a goroutine."** |
| -- The one rule's exclusions, new item: **"5. A PUBLISHED booking page |
| - is a live free/busy oracle for that host, to anyone, without the |
| - database. This is a new disclosure and the publish toggle says so."** |
| -- Mechanical rules → Time: **"`Zone()` takes bounded input on public |
| - routes: `CommonZones` only."** |
| +- Settled decisions: rows B1, B2, B4, B5, B8, B11, B12, B13, B14, B15, |
| + B16, B17. |
| +- Frontend: **"Except a BOOKER, who typed their address to be mailed. |
| + Booking mail is the one outbound to a non-team address."** |
| +- Data: **"Booker name, address, note and TOKEN are sealed; the address |
| + has the keyed fingerprint. A page's public code is generated, never a |
| + chosen slug. Windows are plaintext: shape-of-time."** |
| +- Data: **"Every booking transition is a conditional UPDATE naming the |
| + state and revision it expects, checking RowsAffected. Zero rows is |
| + 'someone moved it first', never a retry."** |
| +- Reminders → **Ticks**: **"`NextDue` is the earliest of the next |
| + reminder, the next hold expiry, the next owed booking mail, and the |
| + next IP-forget. New scheduled work goes here, never in a goroutine. |
| + Reminders get the first half of the drain deadline."** |
| +- Reminders: **"No sender error is definite. Never free a resource |
| + because a send 'failed'."** |
| +- Time: **"`Zone()` takes bounded input on public routes: `CommonZones` |
| + plus the host's own zone."** |
| +- Time: **"A booking slot is the same length on the wall clock and the |
| + elapsed clock, or it is not offered."** |
| +- The one rule's exclusions, item 5: the published-page oracle. |
| --- |
| ## 12. Review |
| -**Round 1 — GPT-6 via `codex-cli 0.153.0`, 2026-09-07. Verdict: |
| -REWRITE.** 21 findings. Dispositions: |
| +Reviewer both rounds: **`gpt-6-astra`** via `codex-cli 0.153.0`, |
| +2026-09-07. |
| + |
| +**Round 1: REWRITE, 21 findings, all accepted** (dispositions were in |
| +revision 2 and are folded into the text above; the round-2 table below |
| +records what still needed work). |
| + |
| +**Round 2: REWRITE.** Of the 21: 12 HOLD; 7 PARTIAL; 4 DO NOT HOLD. |
| +Plus 7 new. Dispositions: |
| -| # | Finding | Disposition | |
| +| Ref | Finding | Disposition | |
| |---|---|---| |
| -| 1 | Plaintext slugs are prose joined to events | **Accepted.** Generated 8-char codes (B4, §2.2). | |
| -| 2 | GET-confirms is confirmed by mail-gateway prefetch | **Accepted, and it was the worst thing in v1.** GET renders a button; POST confirms (B1, §5.3–5.4). | |
| -| 3 | Open-hold caps do not bound mail abuse | **Accepted.** Attempt caps over 24h counting every state incl. `failed`; `MaxReschedules` (§7). | |
| -| 4 | Named IP accessor does not exist; permanent linkage | **Accepted.** Source is the edge's `SetXForwarded`, last XFF element, verified in platform source (§2.5); `ip_fp` nulled at 24h. | |
| -| 5 | `Zone()` cache is an unsynchronised map that caches failures, fed by public input | **Accepted, and it is a pre-existing bug.** Public zone bounded to `CommonZones`; cache made safe and failures uncached; concurrent test (§5.1, §9). | |
| -| 6 | ICS carries the management capability | **Accepted.** No `URL:`; `no-store`; `no-referrer` (B6, §5). | |
| -| 7 | Host's later private edits leak to the booker | **Accepted.** Explicit public allowlist and a test that edits then checks (§5.5). | |
| -| 8 | The oracle is a NEW disclosure | **Accepted.** Reworded; explicit Publish with the sentence (§7.3, §11). | |
| -| 9 | Two strangers hold one slot | **Accepted.** Process mutex + in-mutex check + 20-goroutine race test (B12). | |
| -| 10 | Transitions not atomic | **Accepted.** Transition table, conditional updates, RowsAffected (§5.7). | |
| -| 11 | Two sequence counters; host edits undefined | **Accepted.** Event sequence only; host cancel/delete flows through; RRule refused on booked events (B11, §5.5). | |
| -| 12 | Mail and rows cannot roll back together | **Accepted.** Commit then send; `pending_mail` outbox retried by the drain; `failed` state for a definite hold-mail failure (B13, §6). | |
| -| 13 | Fold example wrong; duration semantics undefined | **Accepted.** Round-trip rule; elapsed duration; window-touching-transition tests (§4.3). | |
| -| 14 | All-day busy from UTC index is wrong in other zones | **Accepted.** Re-derived through `DayBounds` in the host zone (§4.4). | |
| -| 15 | Buffer needs busy outside the range | **Accepted.** Envelope ±1 day, Occ fields (§4.4). | |
| -| 16 | Clamping is not filtering; `day` zone ambiguous | **Accepted.** Per-candidate inequalities; `day` is booker-zone; POST carries the instant (§4.1, §4.6). | |
| -| 17 | Quick-add helpers forgive malformed clocks; overlapping windows | **Accepted.** Raw validation first; windows merged (§2.3, §3.1). | |
| -| 18 | Reminders not wired into transitions | **Accepted.** Default reminder at confirm; reschedule/cancel/expire touch the queue (§5.4–5.7). | |
| -| 19 | Sweep after an early-exit loop never runs; unswept holds still block | **Accepted.** Three independent steps before the loop; lapsed holds excluded from busy by join (§4.4, §8). | |
| -| 20 | Forms underspecified; settings ownership; terms snapshot | **Accepted.** Two-step scriptless form with limits (§5.1–5.2); 404 outside ownership (implementation plan); a booking's terms live on its event, page edits do not touch existing events. | |
| -| 21 | ICS "updates in place" overclaims; From is fixed; METHOD placement | **Accepted.** Download semantics stated; host address in body/ORGANIZER; METHOD on VCALENDAR (B6, §6, §10). | |
| - |
| -Rejected: none. Cut from v1 as a result: chosen slugs, the booking |
| -sequence, the `URL:` line. |
| - |
| -**Round 2** — to be recorded below after the revised spec is reviewed. |
| +| A4 | IP forgetting not scheduled | **Accepted.** `ip_forget_at` column in `NextDueAt` (§2.4, §8). | |
| +| A5 | Host zone may be outside `CommonZones` | **Accepted.** Booker zone ∈ `CommonZones` ∪ {host zone} (§2.4, §5.1). | |
| +| A10 | Reschedule replay applies twice | **Accepted.** Hidden `rev`; `WHERE sequence = rev` (B11, §5.6). | |
| +| A11 | Host cancel not a transition; held-delete absent; stale `+1` | **Accepted.** All three in §5.5/§5.7; `gorm.Expr` everywhere. | |
| +| A12 | Sender errors are not definite | **Accepted — B16.** No `failed` state; the hold stands; the queue retries. | |
| +| A13 | End not round-tripped; elapsed ≠ wall | **Accepted — B17.** Four-part check; straddling slots not offered. | |
| +| A14 | All-day via master dates | **Accepted.** Dates recovered from the Occ instants in the event's zone (§4.4). | |
| +| A16 | `end <= to` loses midnight-crossers | **Accepted.** `to` bounds start (§4.1). | |
| +| A17 | `13pm`; colon-form afternoon rule | **Accepted.** Hour 1–12 with meridiem; hour-component rule stated (§3.1). | |
| +| A18 | Host cancel leaves reminders | **Accepted.** Queue rows deleted; claim path checks status (§5.5). | |
| +| A20 | Ownership unspecified; terms snapshot contradiction | **Accepted.** §2.6. | |
| +| A21 | ICS export contract missing | **Accepted.** `WriteBookingICS` is its own writer; `WriteICS` untouched (§5.5, plan). | |
| +| B1 | Queue cannot rebuild the link | **Accepted — B14.** Token sealed as well as hashed. | |
| +| B2 | One marker loses mail; no lease; no actor | **Accepted — B13.** `booking_mail` table; `cancelled_by`. | |
| +| B3 | Range query drops moved overrides of finished series | **Accepted, and it is a calendar bug.** Fixed in `EventsInRange` with a regression (§4.1). | |
| +| B4 | Mail can block pages and starve reminders | **Accepted.** Reminders first half of the deadline; `timedSender`; `DrainOnRequest` does one thing (§6, §8). | |
| +| B5 | Confirm after the meeting | **Accepted — B15.** Expiry = min(30m, start); confirm guard; notice floor 10 min. | |
| +| B6 | Public limits exceed event limits | **Accepted.** Byte limits: title 100, name 80, note 4000 (§2.2, §5.2). | |
| +| B7 | Public reads unbounded | **Accepted.** `day` validated; one range query per page; per-IP token bucket (§4.2, §7.2). | |
| + |
| +Rejected: none. |
| + |
| +**Round 3** — to be recorded below. |