# Calendar — design

**Status:** built and deployed, 2026-09-02. Authored by Claude Opus 5.
**Not yet reviewed by a model at least as capable as the author, and not
yet approved by Paul.** Both are required before this branch merges;
AGENTS.md §Review is the rule and this line is the record that it has
not happened.

Raw material, including the rejected alternatives:
`docs/superpowers/brainstorms/2026-09-02-calendar-brainstorm.md`
(Fable, 2026-09-02).

**This spec was written after the code, which inverts the house order.**
Recorded rather than hidden: the session built first and documented
second, and the cost is that the rulings below were made while writing
the code rather than before it. What follows is what was actually built,
not what was planned.

---

## 1. The one rule

> **An event's contents are sealed at rest. The bucket never holds a
> plaintext title, place or note.**

Falsifiable: publish the database in full and the worst anyone learns is
the shape of the team's time and who is on the team — never what any
event is, where, or what was written about it.

Enforced by `TestTheDatabaseNeverHoldsAPlaintextTitle`, which greps the
raw SQLite file and the WAL after a `wal_checkpoint(TRUNCATE)`.

### 1.1 Why times are plaintext

Three reasons, and the third is the one that settles it:

1. Every view is a range query over `start_utc` / `end_utc`.
2. `NextDue` reads `MIN(due_at)` at boot, before any request, so the
   reminder queue cannot be behind a handle that opens per request.
3. **The platform's own timer registry holds every reminder instant in
   plaintext anyway** — `carlos.ScheduleAt` sends `at` over the control
   socket. Sealing our copy would claim a property the estate does not
   have.

The consequence is stated rather than buried: a published database gives
up the team's whole free/busy, which is a bigger leak than sheets' "size
of every sheet". AGENTS.md says so in those words.

The deferred alternative — a sealed index the boot path can open before
any request — has a design and no implementation. Trigger: somebody
objects to free/busy being legible.

---

## 2. Ruling 1 — store what the person said

**An event's record is the wall-clock time typed plus the IANA zone
meant. The UTC columns are derived.**

Rejected: storing the instant as truth. A government moves a DST
boundary, a release ships new tzdata, and a 09:00 standup is now at
08:00 or 10:00 with nothing able to say what was meant.

Columns: `start_local`, `end_local`, `tz` are the record;
`start_utc`, `end_utc`, `until_utc` are an index.

**DST.** There is no correct answer and Go does not promise one, so
`clock.go` documents what happens and `clock_test.go` pins it to real
unix numbers, measured against Europe/Dublin 2026:

- The **gap** (spring, 01:00→02:00): `ParseInLocation` uses the
  pre-transition offset, so 01:30 lands at 01:30 UTC and reads back as
  02:30 local. It moves FORWARD, which is the half worth relying on.
- The **fold** (autumn, 02:00→01:00): the SECOND of the two occurrences.

**These are not one rule.** An earlier draft of that comment claimed
both took the pre-transition offset — true of the gap, false of the
fold. A probe corrected it. Do not reason about the fold from the gap.

**A day is 23, 24 or 25 hours**, and `DayBounds` uses `AddDate`. Tested.

**All-day events are DATES**, not datetimes at UTC midnight.

**Every range is half-open**, everywhere.

---

## 3. Recurrence

### 3.1 The subset

`FREQ` DAILY / WEEKLY / MONTHLY / YEARLY, `INTERVAL`, `BYDAY` for
weekly, a single ordinal `BYDAY` or a single `BYMONTHDAY` for monthly,
`COUNT` or `UNTIL`. The UI exposes six choices, labelled against the
event's own start date ("Every Wednesday", not "Weekly").

**A rule outside the subset is stored verbatim, flagged `rrule_foreign`,
shown on the event's page, exported unchanged, and not expanded.** An
importer that eats a rule it does not understand eats data.

### 3.2 The three orderings, each tested

- **COUNT counts GENERATED occurrences; EXDATE removes afterwards.**
  Filtering first gives a four-occurrence series five.
- **A named date a month does not have is NO occurrence.** The 31st is
  skipped in February, never clamped. Same for 29 February yearly and
  the fifth Friday.
- **Expansion iterates in WALL CLOCK.** Stepping a UTC instant by 24
  hours moves a 09:00 standup by an hour for half the year.

Capped at `MaxOccurrencesPerSeries = 1000` per master per request.

### 3.3 Overrides

An override is a row with `series_id` set and `recurrence_id_local` =
the occurrence's **ORIGINAL** local start. It is rendered at whatever
time it now has.

The range query is therefore two passes:

1. Expand each master; substitute any override matching the generated
   occurrence's original key.
2. Add overrides whose own times land in the window but whose original
   did not — the occurrence dragged in from last week.

Skip pass 2 and a moved occurrence vanishes. Skip the dedupe and it
appears twice. Both are tested.

**"This and following" is a SPLIT**, not `RANGE=THISANDFUTURE`: almost
nothing that reads our export supports the range modifier. The head gets
an `UNTIL` the day before the cut, the tail is a new series with a new
UID, and overrides after the cut move to the tail so none are orphaned.

---

## 4. Scriptless layout

**The floor is the product.** Nothing here requires script.

- **Month** is a `<table>`. A month IS tabular data; the table gives a
  screen reader its headers for free. Six rows always, so the page does
  not change height between months. **No spanning bars** — each day says
  what is on it, and `+N more` links to the day.
- **Day and week** are packed **on the server**: events clustered
  transitively by overlap, then greedy lane assignment per cluster, then
  percentages emitted as inline style (rastrillo's CSP permits inline
  styles — its own partials carry them). Per cluster, not per day, so a
  lone four o'clock is not squeezed by two meetings at nine.
- **Click-a-time-to-create** is 48 half-hour `<a>` elements per day
  column, each with an `rst-sr-only` name saying its time, each an
  ordinary GET to a prefilled form.
- **All-day events are not packed** into the column; giving a birthday a
  height would make it look like a 24-hour meeting.
- **320px**: month reflows to a stacked list; week keeps two dimensions
  and scrolls.

Enhancement, currently: rastrillo's own `datetime.js` on the date
fields. Nothing else. **Deferred:** `create.js`, turning a pointer drag
across empty slots into one navigation to `/events/new`. Trigger: Paul's
first canary review says selecting a range by form is painful.

---

## 5. Reminders under hibernation

A `time.Ticker` is not a scheduler; the process is not there when it
fires.

**One queue row per RULE**, holding only the next un-fired occurrence.
A forever-daily standup costs one row; firing it computes the next.

**Three ways a reminder fires**, and the redundancy is the design:

1. `Options.NextDue` answers the activator's poll with
   `MIN(due_at)`. The platform hibernates knowing when to wake, and
   wakes the process for it. This is the precision path.
2. The declared schedule, **every 6 hours**, POSTs `/jobs/reminders`.
   Six and not five minutes: `CARLOS_HIBERNATE_IDLE` defaults to 15
   minutes, so every tick buys a quarter hour of billed residency — a
   5-minute schedule accrues ~730 instance-hours a month for seconds of
   work.
3. Any signed-in page view drains one opportunistically, bounded to one
   per request.

**The tick route is mounted OUTSIDE `csrf.Protect`.** `csrf.Protect`
refuses any POST carrying no `Sec-Fetch-Site`, `Origin` or `Referer`,
and the scheduler sends none. Inside the protected router a tick 403s
forever and the platform reads 4xx as "do not retry" — a silent,
permanent failure. `TestTheTickRouteIsNotBehindCSRF` asserts WHICH
refusal answered, and the deployed instance was checked from outside.

**Work happens inside the request.** 202-plus-a-goroutine is a reliable
way to be hibernated mid-send.

**At-least-once**, and every ambiguous case resolves towards sending: a
reminder that never arrives is worse than one that arrives twice. A
5-minute claim lease bounds the duplicate window. `late_by_sec` is
recorded and the message says how late it was.

Undeliverable after 5 attempts, counted and surfaced on settings —
retrying forever is a queue that never drains on a platform billed by
residency.

---

## 6. ICS

**Import never rejects a file.** It takes what it honestly can and
reports the rest by name: events added, skipped, floating, foreign
rules, dropped attendees, unresolvable zones. `VTIMEZONE` and `VALARM`
blocks are skipped wholesale so their properties cannot land on the
event being built — tested.

**Export** emits IANA `TZID`s and **no `VTIMEZONE`**. Google, Apple and
Thunderbird resolve zones from their own databases; Outlook desktop
historically does not. Stated in the README. Trigger for building it: an
Outlook import of our export shows a wrong time.

Lines fold at **75 octets** on rune boundaries. The round-trip test
found a real conformance bug on its first run: export wrote
`20260902T0930` where the RFC's DATE-TIME is `YYYYMMDDTHHMMSS`, so the
app produced files its own parser rejected. `compactLocal` was serving
both URL occurrence keys and wire values, which is what let them
disagree; `icsDateTime` is now separate.

**Subscription feeds** are capability URLs outside the session, because
Google and Apple will not sign in. Only the token's hash is stored, so
the URL is shown once and the page says so. `ETag`/`304`, because on a
platform billed by residency a 304 is the difference between a feed that
costs a little and one that costs a lot. `poll_count` is shown on the
share page: a feed is not free and the person deciding should see it.

---

## 7. What shipped, and what did not

**Shipped and deployed:** sign-in, calendars and grants, events, five
views, recurrence with occurrence editing and series splitting,
reminders, ICS import/export/feeds, settings, theme switching.

**Shipped since:** quick add — a natural-language box beside New event,
with a live server-parsed detail pane and recorded (never invited)
attendees. Its own spec is `2026-09-02-quick-add-design.md`.

**Not built, each with a trigger:** attendees and guests, web push,
external feed subscription, `VTIMEZONE`, expanding foreign rules, drag
to move, skip-ahead expansion, sealed instants, search, background
import, `RDATE` exactness, working-hours shading, free/busy across
members, per-event colours, attachments, print, "find a time".

**Known incomplete:**

- The `rrule-go` oracle the brainstorm proposed as a test cross-check is
  not wired. The golden vectors are hand-derived, which means they
  encode my reading of the RFC rather than an independent one.
- Locale catalogs are not written; user-visible strings are English
  literals rather than catalog keys. This is a real divergence from the
  suite's twelve-locale floor and is the largest single piece of debt in
  the branch.
- The chromedp browser drive is written and **still does not run in
  this environment**: Chromium starts and sits at `about:blank` until
  Go'''s 600-second timeout, with chromedp'''s goroutines blocked on
  `select`. `TestBrowserWalk` is therefore still dead.

  **The house rule is now met by a different gate.** `make browser`
  runs `TestQuickAddInARealBrowser`, which drives Playwright — Go
  starts the app and signs in, node drives the browser — and it
  crosses from computed value to painted output twice: it measures the
  popover'''s real `getBoundingClientRect()`, and it runs the whole
  feature a second time **with JavaScript disabled** to prove the
  scriptless floor rather than assert it.

  It earned its place on its first run, catching a bug no Go assertion
  could see: the popover rendered ~28px off the right-hand edge of a
  1280px viewport, because it was anchored to the start edge while its
  trigger sits at the far right of the header. Every string test
  passed. Fixed by anchoring to the end edge.

  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.

### Home account connection — 2026-09-10

The approved suite sign-in extension connects a verified Home identity to an
existing Calendar person after fresh local verification and explicit confirmation.
The Home subject never becomes an email address or creates a person, calendar,
event or reminder. Each request resolves the stored person ID and checks current
admission; calendar grants still decide which events that person can read or edit.
Person IDs use SQLite AUTOINCREMENT and must never be reassigned or reused.

Home is optional. Configure the CALENDAR_HOME_ISSUER, CALENDAR_HOME_CONSUMER_ID,
CALENDAR_HOME_KEYS_BASE64 and CALENDAR_HOME_DEK_BASE64 values together. The final
value is a dedicated 32-byte connection key, separate from event encryption.
Partial configuration refuses startup. Without these values, local sign-in keeps
its existing routes and behavior. The shared Home connection package supplies the
proof flow and account screens; Calendar keeps its own page shell and grants.
