oficina / calendar Public

docs: the rules a booking page adds

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 5e69e298c03d547cf7015ba05a43c34c3cf76601 parent 456b28e
2 files changed, +74 −1
  • AGENTS.md +62 −1
  • docs/superpowers/specs/2026-09-02-calendar-design.md +12 −0
diff --git a/AGENTS.md b/AGENTS.md
index 3113245..04cd933 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -43,6 +43,10 @@ pass by reading a file the data has not reached.
pasted into.** The share page says so before the button, not after.
4. **A compromised live host reads everything.** We never claim "we
cannot read your data."
+5. **A PUBLISHED booking page is a live free/busy oracle for that host,
+ to anyone, without the database.** Every calendar the host can see
+ shapes the answer. This is a NEW disclosure, not the one above, and
+ the Publish button says so in a sentence before it is pressed.
---
@@ -58,6 +62,27 @@ pass by reading a file the data has not reached.
| 6 | Delivery is at-least-once; every ambiguous case resolves towards sending | At-most-once — a reminder that never arrives is worse than one that arrives twice | This session, 2026-09-02 |
| 7 | Sign-in is a magic link, with no passkey second factor | Passkeys — `passkey.Config` derives its relying party from this instance's host, so one person in three team instances enrols three credentials. Same ruling docs made | This session, 2026-09-02 |
| 8 | A new event defaults to the person's PERSONAL calendar | Defaulting to the team calendar — the cost of forgetting to change a dropdown should fall on the side of privacy | This session, 2026-09-02 |
+| 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, which every link-scanning mail gateway would press | Booking spec §1, 2026-09-07 |
+| B2 | Everything the host can see is busy — every calendar they hold a grant on | Personal only | Booking spec §1, 2026-09-07 |
+| B4 | The public identifier is a chosen plaintext `handle` plus a GENERATED page code | A chosen slug — prose joined to events | Booking spec §1, 2026-09-07 |
+| 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" | Booking spec §1, 2026-09-07 |
+| B8 | A held slot is a tentative EVENT | A holds table the slot query consults | Booking spec §1, 2026-09-07 |
+| B11 | The EVENT's `sequence` is the one revision counter, bumped as `sequence + 1`; every booking-side write names the revision it expects | A booking sequence; a stale-read `+1` | Booking spec §1, 2026-09-07 |
+| B12 | Hold and reschedule serialise on a process 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` | Booking spec §1, 2026-09-07 |
+| 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 | Booking spec §1, 2026-09-07 |
+| B14 | The management token is 32 random bytes, hex, stored SEALED as well as hashed | Hash only, as feeds do | Booking spec §1, 2026-09-07 |
+| B15 | A hold expires at the EARLIER of thirty minutes and the meeting's start; confirmation requires now < start | Thirty minutes flat | Booking spec §1, 2026-09-07 |
+| B16 | No sender error is "definite"; nothing is freed because a send failed | Marking holds failed | Booking spec §1, 2026-09-07 |
+| 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 | Booking spec §1, 2026-09-07 |
+| B18 | A booked event's scheduling — start, end, tz, all_day, kind, rrule — belongs to the booking flow; the host's form shows them disabled and a save that changes any is refused | Invariant checks on host-supplied times | Booking spec §1, 2026-09-07 |
+| B19 | Mail says the CURRENT state; a transition deletes every unsent row the new state makes false | Historical snapshots per row | Booking spec §1, 2026-09-07 |
+| B20 | The booker sees the page's title and description AS THEY WERE at hold time, snapshotted sealed onto the booking | Live page text | Booking spec §1, 2026-09-07 |
+| B21 | Reschedule writes `tz` = the host's current zone atomically with both local strings and both UTC columns | Leaving `tz` as it was | Booking spec §1, 2026-09-07 |
+| B22 | Both queues carry a random `claim_id`; every completion mutation is conditional on it and built from the row the claim returned; reminders retry through `retry_at`, never `due_at`; `ErrMailBusy` defers without counting an attempt | Claim by timestamp; retry written into `due_at` | Booking spec §1, 2026-09-07 |
+| B23 | Availability FAILS CLOSED: a busy series that could not be expanded to completion, or whose rule this app cannot parse, is a 503, never "free" | Trusting `Expand`'s slice | Booking spec §1, 2026-09-07 |
+| B24 | Reschedule repairs the event's reminder queue rows INSIDE its transaction | After commit | Booking spec §1, 2026-09-07 |
+| B25 | A booked event's form carries its scheduling snapshot as hidden inputs, including the event's own `tz`, with the controls disabled | Disabled controls alone, which submit nothing | Booking spec §1, 2026-09-07 |
+| B26 | Reschedule enumerates the event's reminder RULES and upserts a queue row for each | Rebuilding the rows that exist | Booking spec §1, 2026-09-07 |
Don't relitigate one in a drive-by.
@@ -78,6 +103,10 @@ relying on remembering one, make it a test instead.
the pre-transition offset, the fold takes the second occurrence. Do
not reason about one from the other.
- **A day is 23, 24 or 25 hours.** Use `DayBounds`, never `Add(24h)`.
+- **`Zone()` takes bounded input on public routes:** `CommonZones` plus
+ the host's own zone. Its cache holds successes only.
+- **A booking slot is the same length on the wall clock and the elapsed
+ clock, or it is not offered.** Both ends round-trip through `ToUTC`.
- **Every range is half-open.** An inclusive end is how a one-day event
shows on two days and how a week query double-counts its boundary.
- **All-day events are DATES.** Never a datetime at UTC midnight, which
@@ -85,6 +114,11 @@ relying on remembering one, make it a test instead.
**Recurrence**
- **COUNT counts GENERATED occurrences; EXDATE removes afterwards.**
+- **`Expand` may stop early.** Anything that decides FREE time reads
+ `EventsInRangeStrict` and fails closed on `ErrIncompleteExpansion`.
+- **A day span is a difference of CIVIL dates, never elapsed hours over
+ 24.** And advancement carries the series' ORIGINAL clock onto each
+ new date — never the previous occurrence's resolved time.
- **A named date a month does not have is NO occurrence** — never
clamped. The 31st, 29 February, the fifth Friday.
- **Overrides are keyed by the occurrence's ORIGINAL local time and
@@ -98,6 +132,16 @@ relying on remembering one, make it a test instead.
- Every scoped read goes through a grant join. **A row that isn't yours
404s, never 403s** — including for a write, so status codes cannot
enumerate the team's calendars.
+- **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 — a slug is prose. Booking windows are plaintext:
+ shape-of-time.**
+- **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.
+- **A booked event's start, end, tz, all_day, kind and rrule belong to
+ the booking flow.** A host edit that changes any of them is refused;
+ the form shows them disabled with the stored values hidden beneath.
- **Never bind a request body onto a model.** Updates take an explicit
column allowlist; the columns absent from it (`calendar_id`,
`created_by`, `series_id`) are exactly the ones a hidden field must
@@ -108,8 +152,23 @@ relying on remembering one, make it a test instead.
Use `auth.From(r)`, and `requirePerson` for the id.
- Migrations are additive-only. Never edit one that has shipped.
-**Reminders**
+**Ticks**
- **A `time.Ticker` is not a scheduler.** The instance hibernates.
+- **`NextDue` is the earliest CLAIMABLE instant** across reminders, hold
+ expiries, owed booking mail and IP-forgets. New scheduled work goes
+ here, never in a goroutine. Cheap work runs before delivery;
+ reminders get the first half of the delivery budget.
+- **Every queue row has a random `claim_id`;** every completion
+ mutation is conditional on it and checks RowsAffected, and a payload
+ is built from the row the claim returned, never from the candidate
+ read. A reminder's `due_at` is the occurrence minus the offset and is
+ never written by a retry — `retry_at` is. Booking mail's `due_at` is
+ retry eligibility only.
+- **No sender error is definite.** Never free a resource because a send
+ "failed". Every send goes through `boundedSender`; `ErrMailBusy` is a
+ deferral, not an attempt.
+- **A reschedule repairs the event's queue rows in the same transaction
+ as the move.**
- **The tick route is mounted OUTSIDE `csrf.Protect`.** `csrf.Protect`
refuses any POST with no `Sec-Fetch-Site`, `Origin` or `Referer`, and
the scheduler sends none; inside the protected router a tick 403s
@@ -143,6 +202,8 @@ relying on remembering one, make it a test instead.
surface that names one says so, and their addresses are sealed with a
KEYED fingerprint for lookup — an unkeyed hash of an address is that
address in a different alphabet.
+- **Except a BOOKER, who typed their address to be mailed.** Booking
+ mail is the one outbound to a non-team address.
**Browser**
- `make browser` is the painted-output gate: Playwright, run from
diff --git a/docs/superpowers/specs/2026-09-02-calendar-design.md b/docs/superpowers/specs/2026-09-02-calendar-design.md
index c86f847..6ad4672 100644
--- a/docs/superpowers/specs/2026-09-02-calendar-design.md
+++ b/docs/superpowers/specs/2026-09-02-calendar-design.md
@@ -279,3 +279,15 @@ members, per-event colours, attachments, print, "find a time".
Remaining work is porting `TestBrowserWalk` off chromedp, not
writing a gate.
- UI copy has not been through the `copy-review` gate.
+- rastrillo's `mail.SMTP` ignores its context and sets no dial or
+ protocol deadline, so an abandoned send holds a goroutine and a socket
+ until the kernel closes it. Bounded to four in flight here by
+ `boundedSender`; fixed properly upstream.
+
+**Shipped since, again:** booking pages — a public "book time with me"
+link per host, with plain-text availability, a mailed link whose button
+confirms, a tokenised ICS, and booker-side reschedule and cancel. Its
+own spec is `2026-09-07-booking-design.md`, reviewed eight times by
+GPT-6 before code; the review found six defects in this engine's
+recurrence expansion that every view already had, all fixed on the
+same branch.