oficina / calendar Public

docs: the calendar brainstorm

Fable's brainstorm, in the house spec voice: the one rule and its two
rejected candidates, the five hard problems a calendar has (recurrence,
zones, scriptless layout, reminders under hibernation, ICS) with a
decision on each, a ruthlessly ordered scope, the data model, the route
table, and twenty things calendars get wrong.

Raw material for the spec, kept as its own document because the
rejected alternatives are the expensive part and a spec that only
records what was chosen loses them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QG3FX6qzynri8yTgMUm7q7
Paul Campbell pushed by paul@keymail.dev 9abf873630e24b63358aacfede80cf6098bce687 parent cad3559
1 file changed, +1053 −0
  • docs/superpowers/brainstorms/2026-09-02-calendar-brainstorm.md +1053 −0
diff --git a/docs/superpowers/brainstorms/2026-09-02-calendar-brainstorm.md b/docs/superpowers/brainstorms/2026-09-02-calendar-brainstorm.md
new file mode 100644
index 0000000..a1de4ae
--- /dev/null
+++ b/docs/superpowers/brainstorms/2026-09-02-calendar-brainstorm.md
@@ -0,0 +1,1053 @@
+# πŸ€– Calendar β€” brainstorm
+
+**Status:** brainstorm, 2026-09-02. Raw material for the design spec, not the
+spec. Every "ruled" below is a *proposal* for Paul; nothing here is settled
+until it lands in the spec's Β§1 table with his name beside it.
+**Scope:** the Calendar instance app, beside Sheets and Docs under Oficina.
+Where a decision is already made over there, this document consumes it and
+says so rather than restating it.
+
+The user's floor for tonight: **calendar layout, adding an event, reminders,
+ICAL**, deployed. Everything else here is either argued into the overnight
+cut with a reason, or deferred with a written trigger.
+
+---
+
+## 0. What was read, and the three facts that shaped this
+
+Read in full: `sheets-core/AGENTS.md`, `sheets-core/PRODUCT.md`,
+`home/AGENTS.md`, `docs/AGENTS.md`, `estilo/AGENTS.md`, the Sheets design
+spec (all sections), Docs Β§2, Β§6.2, Β§7.3, Β§14–15, the home spec Β§0–3, and
+rastrillo v0.23.0's `SKILL.md`, `README.md`, `carlos/`, `mail/`, `serve.go`
+and `seal` (Sheets' copy).
+
+Three verified facts do most of the work in this document:
+
+1. **The platform owns the clock, and offers two handles.**
+ `rastrillo.Options.NextDue func() time.Time` answers the activator's
+ `GET /api/next-due` poll before hibernation β€” the instance is woken at
+ that instant. `carlos.ScheduleAt(ctx, name, at, path)` registers a
+ one-shot timer in the *box's* registry (survives restarts, upsert by
+ name, `^[a-z0-9][a-z0-9-]{0,31}$`, **400-day horizon, 1000 pending per
+ host**) and the platform POSTs to `path` when due. Ticks are
+ at-least-once, guarded by `carlos.Tick(r)`, and **the work must finish
+ inside the request** β€” 202-plus-goroutine hibernates mid-job. Off
+ platform both degrade to `ErrNotOnCarlos`. (`carlos/carlos.go`,
+ `carlos/schedule.go`, `serve.go:122–130`.)
+2. **The framework already ships the scriptless date input.**
+ `field-date`, `field-time`, `field-datetime`, `field-daterange` partials,
+ `form.Range(p, "starts", "ends")`, and `datetime.js` β€” a combobox reading
+ "next fri 9am" in the page's own locale, vocabulary from the catalog,
+ month names from `Intl`. Quick-add is mostly free. (`SKILL.md:185–195`.)
+3. **The home's assertion helper is still an open gap** (Sheets Β§15, Docs
+ Β§15.4). Docs signs in with a local magic link plus `$DOCS_ALLOWED_ADDRESSES`.
+ Calendar does the same tonight and migrates when the helper lands.
+
+---
+
+## 1. The one rule
+
+Three candidates, then the argument.
+
+### Candidate A β€” sealed at rest, the Sheets shape
+
+> **An event's contents are sealed at rest. The bucket never holds a
+> plaintext title, place, note or name.**
+
+The falsifiable form: **publish this database in full and the worst anyone
+learns is the shape of the team's time β€” when they are busy, for how long,
+how often, and in which time zones β€” and who is on the team. Never what any
+event is, where it is, or what was written about it.**
+
+Test from the first commit: the plaintext grep over the raw SQLite file and
+WAL, seeded with a fixture calendar whose titles, locations and notes are
+distinctive strings. `plaintextExempt` starts empty.
+
+### Candidate B β€” store what the person said
+
+> **Every event is stored as the wall-clock time the person typed and the
+> zone they typed it in. The instant is derived, never authored.**
+
+Falsifiable: change the binary's embedded tzdata, run the re-derive sweep,
+and every event still reads the same wall-clock in its own zone. A test
+builds with a doctored zone, re-derives, and asserts `start_local` and `tz`
+are byte-identical while `start_utc` moved.
+
+### Candidate C β€” a reminder fires once and never silently late
+
+> **A reminder is delivered at least once, at most twice, and its lateness
+> is visible.**
+
+Falsifiable: kill the process at every line between claim and mark; restart;
+the occurrence is delivered, the duplicate (if any) carries the same
+`Message-ID`, and the queue row records how late it was.
+
+### The argument
+
+**A is the one rule.** It is the family's shape β€” Sheets, Docs and the home
+all open with a storage rule with a `publish the bucket and…` test β€” and it
+is the only one of the three that constrains *every* table from the first
+migration. B is the most important *correctness* decision in the app and
+belongs in the settled-decisions table as ruling 1, with its test as a
+gate. C is a testing gate, not a rule; it constrains one subsystem.
+
+**A is also harder to keep than Sheets' version, and the spec must say so.**
+A spreadsheet's plaintext shape β€” coordinates, sizes, edit timestamps β€” is
+nearly meaningless. A calendar's plaintext shape is *free/busy*, which
+companies sell as a product. "Busy every Tuesday 14:00–15:00, recurring
+weekly, reminder one day before" is a lot to know about a person. The
+honest leak list (Β§4.4) says this in words, and the README does too.
+
+**Why times stay plaintext anyway, tonight.** Three reasons, and the third
+is the one that ends the argument:
+
+- Every view is a range query. Month view is `start_utc < ? AND end_utc >
+ ?`; sealing instants means decrypting every row per page or holding a
+ decrypted index in memory.
+- `NextDue()` must answer from the database at boot, before any handle is
+ open, with `SELECT MIN(due_at) … WHERE fired_at IS NULL`.
+- **The platform's timer registry holds every reminder's fire instant in
+ plaintext regardless** (`ScheduleAt` sends `at` over the control socket
+ and the box keeps it). Sealing instants in our file while they sit
+ readable in the box's registry would be claiming a property the estate
+ does not have.
+
+So the rule forbids *content* at rest and admits *shape*, and says which is
+which. A sealed-instants design with an in-memory index and a `NextDue`
+that does not need plaintext is in the deferred table (Β§7) with a real
+trigger, because at dogfood N it is feasible and someone will ask.
+
+---
+
+## 2. Recurrence
+
+RRULE is a swamp because RFC 5545 Β§3.3.10 is a grammar for every rule
+anyone could want, and calendars need the six rules anyone ever uses.
+
+### 2.1 The subset that ships β€” and it is Google's UI, not a compromise
+
+Google Calendar's own repeat picker offers exactly: daily, weekly on chosen
+days, monthly on day N, monthly on the Nth weekday, yearly, and "custom"
+with an interval and an end. That is the subset:
+
+| Ships | Form |
+|---|---|
+| `FREQ=DAILY\|WEEKLY\|MONTHLY\|YEARLY` | β€” |
+| `INTERVAL=n` | every 2 weeks, every 3 months |
+| `BYDAY` for WEEKLY | `MO,WE,FR` |
+| `BYDAY` with one ordinal for MONTHLY | `2TU` (second Tuesday), `-1FR` (last Friday) |
+| `BYMONTHDAY` single value for MONTHLY | `15`, `-1` (last day) |
+| `COUNT=n` or `UNTIL=…`, or neither | forever is allowed |
+| `WKST` | honoured on import, `MO` on export |
+
+**Not in the subset:** `BYSETPOS`, `BYYEARDAY`, `BYWEEKNO`, `BYHOUR` and
+below, `HOURLY`/`MINUTELY`/`SECONDLY`, multiple `BYDAY` on MONTHLY,
+multiple `BYMONTH`, `RDATE` lists as a first-class thing.
+
+**Hand-roll the expander, ~250 lines, and use `teambition/rrule-go` as a
+test oracle only.** The expander's contract with our zone model (Β§3) is
+the thing that must be ours: it iterates in *wall-clock* in the event's
+zone and resolves each occurrence to an instant afterwards, so a 09:00
+weekly meeting stays 09:00 across a DST change. A runtime dependency would
+be doing the same arithmetic one abstraction away from our own rules. The
+oracle catches the cases where I am wrong about the RFC: MONTHLY on the
+31st (skip months without one β€” RFC says "MUST be ignored", not "clamp"),
+`-1FR` in a five-Friday month, `COUNT` interacting with `EXDATE` (COUNT
+counts generated instances *before* exclusion), Feb 29 yearly (skipped in
+non-leap years per RFC; Google shows Feb 28 and we do not β€” say so in the
+UI copy for that one case).
+
+**A foreign rule β€” one outside the subset β€” is imported, never refused.**
+It is stored verbatim in `rrule`, flagged `rrule_foreign=1`, the master
+renders once at its `DTSTART` with a marker "repeats on a rule Calendar
+cannot show yet", exports back byte-identical, and the series editor
+offers only "replace this rule" (with the subset picker). The count of
+foreign rules is logged on import; the rules themselves are not (a rule is
+structure, not content, but the line stays clean). Deferred: expanding
+foreign rules (Β§7).
+
+### 2.2 Exceptions and overrides β€” the RFC model, because it round-trips
+
+Three kinds of row in one `events` table:
+
+- **Single event** β€” `rrule=''`, `series_id NULL`.
+- **Series master** β€” `rrule!=''`, `series_id NULL`. Carries `exdates`
+ (deleted occurrences, as local wall-clock datetimes) and `until_utc`.
+- **Override** β€” `series_id = master.id`, `recurrence_id_local = the
+ original occurrence's wall-clock`, and a full copy of every editable
+ field. This is RFC 5545 `RECURRENCE-ID` exactly, so an override imports
+ and exports without translation.
+
+Deletion of one occurrence is an `EXDATE` on the master, **not a tombstone
+row** β€” a deletion is not content, the column is one line, and it exports
+exactly. Modification of one occurrence is an override row. An override
+that has no matching generated occurrence (an imported `RDATE`, or an
+occurrence the rule stopped generating after a rule edit) is still shown β€”
+it is an addition, and the recurrence-set algorithm below treats it as one.
+
+**"This and following" is a split, not a `RANGE=THISANDFUTURE`.** The
+master gets `UNTIL = the occurrence before`, a new master is created with
+`DTSTART = this occurrence` and the new values, and every override whose
+`recurrence_id β‰₯ split point` is re-parented in the same transaction. The
+tail gets a new UID. That is what Google and Apple do; `RANGE=THISANDFUTURE`
+is in the RFC and in almost no shipping implementation, and an export
+carrying it would be mis-read by the tools people actually use.
+
+**Editing "all" of a series with overrides:** the master's fields change;
+overrides keep their own values (that is what an override means); the
+question "also reset the N exceptions?" is asked, defaulting to no.
+
+**Deleting a series deletes its overrides in the same transaction.** Named
+because the orphan is the classic bug (Β§9.13).
+
+### 2.3 Expanding for a view without materialising infinity
+
+Occurrences are **never rows**. They are computed per request, inside a
+window `[view_start, view_end)` in the viewer's zone:
+
+1. **Timed singles and overrides** by their *actual* time:
+ `start_utc < view_end AND end_utc > view_start`.
+2. **All-day singles and overrides** by date text:
+ `start_local < 'view_end_date' AND end_local > 'view_start_date'`
+ (ISO dates compare lexically, and an all-day event has no instant).
+3. **Masters that could intersect:** `dtstart_utc < view_end AND (until_utc
+ IS NULL OR until_utc >= view_start)`. For each, the expander iterates
+ from `DTSTART`, discarding occurrences before the window, until it
+ passes the window or hits `COUNT`/`UNTIL`. A daily series ten years old
+ is ~3,650 cheap iterations; fine at dogfood N. A **hard cap of 1,000
+ generated occurrences per master per request** is the guard against a
+ hostile or broken rule; past it the series renders a marker and stops.
+4. **The recurrence set** = generated βˆ’ `exdates` βˆ’ occurrences that have an
+ override (matched on `recurrence_id_local`) + overrides from steps 1–2
+ (matched on their *new* time). Overrides are queried by where they
+ *moved to*, never by where they came from β€” querying by
+ `recurrence_id` misses an occurrence dragged into the window from
+ outside it, which is the second classic bug.
+5. **Occurrence duration is a wall-clock delta.** `end_local βˆ’ start_local`
+ as a calendar difference, applied to each occurrence's wall-clock start
+ β€” so a 09:00–10:00 meeting is 09:00–10:00 on both sides of a DST change,
+ which is what "till ten" means to a person.
+
+**An occurrence's address is `/events/{id}/{yyyymmddThhmm}`** β€” the master
+id plus the *original* occurrence's wall-clock. That URL is how a scriptless
+page opens "this Tuesday's standup"; the edit form on it carries a
+`scope=this|following|all` radio.
+
+Skip-ahead arithmetic (first occurrence β‰₯ window start in O(1) for
+DAILY/WEEKLY) is deferred with a measured trigger (Β§7).
+
+---
+
+## 3. Time zones
+
+This is where calendars die, and the reason is always the same: the app
+stored the answer (an instant) and threw away the question (what the person
+meant).
+
+### 3.1 Ruling 1 β€” store what the person said
+
+Every event row stores:
+
+- `start_local`, `end_local` β€” ISO 8601 wall-clock to the minute, no
+ offset: `2026-09-02T09:00`. For all-day events, dates: `2026-09-02`, end
+ **exclusive** (the RFC's rule; the UI shows inclusive and the conversion
+ happens in exactly one function with a test).
+- `tz` β€” an IANA name (`Europe/Dublin`), `''` for floating, ignored for
+ all-day.
+- `all_day` β€” the third kind, not a midnight-to-midnight timed event.
+- `start_utc`, `end_utc` β€” **derived** unix seconds, for indexes and range
+ queries only. Never edited, never trusted as intent.
+
+Go embeds `time/tzdata`; the binary stamps its tzdata version into a `meta`
+row at boot. If the stamp differs from the last boot, a **re-derive sweep**
+recomputes every `*_utc` column (a few thousand rows; log the count, never
+a title), re-queues every pending reminder (Β§5), and re-asserts the
+platform timer. A zone abolishing DST next year moves the instant of every
+future 09:00 meeting there and leaves `start_local` untouched, which is
+correct and is the whole point.
+
+### 3.2 The three kinds, and which the UI can make
+
+| Kind | RFC form | Stored | Rendered | UI can create? |
+|---|---|---|---|---|
+| **All-day** | `DTSTART;VALUE=DATE` | dates, no zone | on that date in every viewer's zone | yes |
+| **Zoned** | `DTSTART;TZID=…` | wall-clock + IANA zone | converted to the viewer's zone | yes β€” the default |
+| **Floating** | `DTSTART` bare | wall-clock, `tz=''` | at that wall-clock in *whatever* zone the viewer is in | **no** β€” import and export only |
+
+Floating is refused in the UI on purpose. On a *shared* calendar a floating
+09:00 is read as the same instant by two people in two zones, and it is
+not. It exists in the model because the RFC requires it and files contain
+it; a floating event renders with a marker ("floating β€” 09:00 wherever you
+are") and its edit form offers to pin it to a zone.
+
+### 3.3 The viewer's zone is stated, never sniffed
+
+Scriptless has no `Intl.DateTimeFormat().resolvedOptions().timeZone`, and
+that is a feature. A person's zone is a **preference on their member row**,
+defaulted from the instance's team zone at first sign-in, changed in
+settings. The one enhancement `datetime.js` may add is a *notice* β€” "your
+browser says America/New_York; you are set to Europe/Dublin. Switch?" β€” with
+a real form behind it. Never a silent rewrite. The "my whole calendar
+shifted eight hours when I landed" horror is a sniff.
+
+The team zone is `$CALENDAR_TZ` at provisioning, editable in settings. It
+is used for ICS `X-WR-TIMEZONE` and as the member default, nothing else.
+
+The zone is per person, not per team, so it belongs with the home's display
+preferences eventually β€” it fails cosmetically under a hostile value, which
+is the home spec's test for a claim riding an assertion. Deferred with the
+same trigger as theme (Β§7).
+
+### 3.4 Rendering across zones
+
+- **The primary time is always the viewer's.** An event authored in Dublin
+ by a Dubliner and viewed from Lisbon shows at 08:00, with "09:00 Dublin"
+ as secondary text β€” only when the zones differ. Never a `(GMT+1)` suffix
+ on everything.
+- **An event that crosses the viewer's midnight is split into two chips**
+ in day and week grids, server-side, with continuation markers `β†’` and
+ `←` that carry `aria-label` text. The event's own date is irrelevant to
+ where it renders; the viewer's date governs.
+- **"Today" is computed in the viewer's zone.** A server in UTC deciding
+ what "today" is for someone in Auckland is bug Β§9.16.
+
+### 3.5 Gaps and folds
+
+01:30 on a spring-forward day does not exist; on a fall-back day it exists
+twice. `time.Date` in the location normalises; Go documents that the choice
+"is not guaranteed". We accept Go's choice, **pin it with a test so it is
+known rather than assumed**, and do not pretend to do better. It affects one
+hour a year between 01:00 and 03:00.
+
+`UNTIL` must be UTC when `DTSTART` is zoned (RFC), and half the exporters in
+the wild write it local. **Lenient on import** (a bare `UNTIL` is read in
+the event's zone), **strict on export** (always `Z`).
+
+---
+
+## 4. Data model
+
+### 4.1 Tables
+
+```go
+// Member β€” who may hold a session. Address plaintext by the home spec's
+// ruling 5 (the sharer typed it; the team already knows it).
+type Member struct {
+ ID uint64 `gorm:"primaryKey"`
+ Address string `gorm:"uniqueIndex"`
+ PersonRef string // from the home, once the contract is consumed; '' until then
+ NameSealed []byte // seal purpose "member.name"
+ TZ string // IANA; defaults to the team zone
+ WeekStart int // 1 = Monday (default), 7 = Sunday, 6 = Saturday
+ DefaultReminderMin int // applied to timed events they create; 10
+ DefaultAllDayMin int // applied to all-day events; 15*60 (09:00 the day before)
+ CreatedAt time.Time
+}
+
+// Calendar β€” a container. One "team" calendar per instance, personal ones
+// per member. Colour is an OKLCH intent, as Sheets' fills are.
+type Calendar struct {
+ ID uint64 `gorm:"primaryKey"`
+ NameSealed []byte // seal purpose "calendar.name"
+ Kind string // "team" | "personal"
+ OwnerID uint64
+ HueIntent float64
+ ChromaIntent float64
+ CreatedAt time.Time
+ ArchivedAt *time.Time `gorm:"index"`
+}
+
+// Grant β€” the hand-written grants join. Explicit rows even for the team
+// calendar (every member is granted editor on it at creation), so one
+// query shape serves every calendar and 404-not-403 has one home.
+type Grant struct {
+ ID uint64 `gorm:"primaryKey"`
+ CalendarID uint64 `gorm:"uniqueIndex:idx_grant_cal_member"`
+ MemberID uint64 `gorm:"uniqueIndex:idx_grant_cal_member"`
+ Role string // "owner" | "editor" | "viewer"
+}
+
+// Event β€” single, series master, or override (see Β§2.2).
+type Event struct {
+ ID uint64 `gorm:"primaryKey"`
+ CalendarID uint64 `gorm:"index"`
+
+ // Identity for ICS. The hash is the lookup key (re-import dedupes on
+ // it); the sealed copy is what exports. UIDs are sealed because some
+ // generators build them from the title.
+ UIDHash string `gorm:"index"`
+ UIDSealed []byte // seal purpose "event.uid"
+
+ // Series linkage. An override has both; a master has neither.
+ SeriesID *uint64 `gorm:"index"`
+ RecurrenceIDLocal string // "2026-09-02T09:00" β€” the ORIGINAL occurrence
+
+ TitleSealed []byte // "event.title"
+ LocationSealed []byte // "event.location"
+ NotesSealed []byte // "event.notes"
+
+ // Intent β€” ruling 1.
+ AllDay bool
+ StartLocal string // "2026-09-02T09:00" or "2026-09-02"
+ EndLocal string // exclusive for all-day
+ TZ string // IANA; "" = floating; ignored when AllDay
+
+ // Derived, index-only. Rebuilt by the re-derive sweep. Plaintext.
+ StartUTC int64 `gorm:"index"`
+ EndUTC int64 `gorm:"index"`
+
+ // Recurrence β€” plaintext structure, published in the README.
+ RRule string // "" for singles and overrides
+ RRuleForeign bool
+ UntilUTC *int64 `gorm:"index"`
+ Exdates string // comma-separated local datetimes
+
+ Kind string // "meeting" | "commitment" | "note" β€” Β§8.2
+ Status string // "confirmed" | "tentative" | "cancelled"
+ Sequence int // RFC SEQUENCE; bumped on every edit
+
+ CreatedBy uint64
+ CreatedAt time.Time
+ UpdatedAt time.Time // exported as DTSTAMP / LAST-MODIFIED
+ DeletedAt *time.Time `gorm:"index"`
+}
+
+// Reminder β€” the RULE, and it is per PERSON (Β§5.3). "Remind me 10 minutes
+// before" is a fact about me, not about the event.
+type Reminder struct {
+ ID uint64 `gorm:"primaryKey"`
+ EventID uint64 `gorm:"index"`
+ MemberID uint64 `gorm:"index"`
+ OffsetMin int // before start; negative = after
+ Channel string // "email" tonight
+}
+
+// ReminderQueue β€” the NEXT un-fired occurrence per rule, and only that.
+// Bounded by the number of rules, not by the length of any series.
+type ReminderQueue struct {
+ ID uint64 `gorm:"primaryKey"`
+ ReminderID uint64 `gorm:"uniqueIndex"` // at most one pending row per rule
+ OccurrenceKey string // "{event_id}/{recurrence_id_local}" β€” the idempotency key
+ DueAt int64 `gorm:"index"`
+ ClaimedAt *int64
+ FiredAt *int64
+ Attempts int
+ LateBySec int64 // recorded at fire; the honesty column
+}
+
+// Feed β€” a subscription capability. Token hashed, per the share-link
+// ruling. Per person: it exports that person's calendars and reminders.
+type Feed struct {
+ ID uint64 `gorm:"primaryKey"`
+ MemberID uint64 `gorm:"index"`
+ CalendarID uint64 `gorm:"index"`
+ TokenHash string `gorm:"uniqueIndex"`
+ CreatedAt time.Time
+ RevokedAt *time.Time
+ LastPolledAt *time.Time
+ PollCount int64
+}
+
+// Meta β€” key/value. "tzdata_version", "schema".
+type Meta struct {
+ Key string `gorm:"primaryKey"`
+ Value string
+}
+```
+
+**Not created tonight, designed so its arrival is additive:** `attendees`
+(`event_id`, `member_id NULL`, `address_hash`, `address_sealed`,
+`name_sealed`, `partstat`, `role`). Tonight's import counts `ATTENDEE` lines
+and says how many were dropped.
+
+### 4.2 Seal purposes
+
+`oficina/calendar:` + `event.title`, `event.location`, `event.notes`,
+`event.uid`, `calendar.name`, `member.name`. Lifted from Sheets' `seal`
+package unchanged except for the label prefix; golden vectors re-pinned.
+
+### 4.3 Sealed versus plaintext, with the reason for each
+
+| Column | At rest | Why |
+|---|---|---|
+| title, location, notes | sealed | content |
+| calendar name, member display name | sealed | content (Sheets seals titles and tab names) |
+| UID | sealed + SHA-256 | some generators derive it from the title; the hash is the re-import key |
+| member address | **plaintext** | home spec ruling 5; reminder email needs it; the team typed it |
+| `start_local`, `end_local`, `tz`, `*_utc`, `until_utc`, `exdates` | **plaintext** | every view is a range query; `NextDue` reads it at boot; the platform holds the instants anyway (Β§1) |
+| `rrule` | **plaintext** | structure, not prose β€” the same class as a format record; reveals nothing the instants don't |
+| kind, status, sequence, colours, ids, timestamps | plaintext | shape |
+| reminder offsets, `due_at`, `occurrence_key` | plaintext | the queue is what `NextDue` reads |
+| feed token | hashed | a stored plaintext token is a live read capability in the bucket |
+
+### 4.4 The leak list, stated once and never contradicted
+
+Publish the database and you learn:
+
+1. **The team's free/busy in full**, past and future, including recurrence
+ patterns and which zone each event was authored in. This is a bigger
+ leak than Sheets' "shape and size of every sheet", and the README says
+ so in those words.
+2. Who is on the team, by readable address, and who owns which calendar.
+3. Who set reminders how far ahead, and when the next one fires.
+4. That some events are cancelled or tentative, and how many are meetings
+ versus commitments versus notes.
+
+You learn **no title, place or note**, and no name of anyone outside the
+team (there are none yet β€” attendees are deferred).
+
+Three leaks *outside* the database, listed because a rule that only covers
+the bucket invites reading them as covered:
+
+5. **A reminder email carries the title** through the mail relay and into
+ the recipient's mailbox. That is delivery, not storage, and it is the
+ product working β€” but the relay's logs are not ours.
+6. **A subscription feed puts the calendar's plaintext in whatever the
+ subscriber pointed at it** β€” Google, Apple, a phone. The token is in a
+ URL in someone else's account (Β§6.4).
+7. **The platform's timer registry holds reminder instants** (Β§1).
+
+A compromised live host reads everything, as in Sheets. **We never claim
+"we can't read your data."**
+
+---
+
+## 5. Reminders
+
+home/AGENTS.md: *"A dormant instance runs no timers… This shipped twice in
+one spec before it was caught."* So the design starts from the premise that
+the process is not there when the reminder is due.
+
+### 5.1 One queue, one due-time, two ways to be woken
+
+- `reminders` is the **rule** ("10 minutes before, by email, for me").
+- `reminder_queue` holds **exactly one pending row per rule**: the next
+ un-fired occurrence, computed by the expander (first occurrence whose
+ `start βˆ’ offset > now`, honouring `exdates` and overrides β€” an override's
+ *moved* start governs its reminder). When a row fires, the rule's next
+ occurrence is computed and enqueued. The queue is bounded by the number
+ of rules, so a forever-daily standup costs one row, not infinity.
+- `NextDue()` is `SELECT MIN(due_at) FROM reminder_queue WHERE fired_at IS
+ NULL`. The activator polls it before hibernating and wakes us then.
+- **Belt and braces: one named platform timer.** On every queue mutation,
+ `carlos.ScheduleAt(ctx, "reminders", nextDue, "/jobs/reminders")` β€”
+ upsert by name, so it is always the single next instant. One timer per
+ instance, never one per reminder, because the host cap is 1,000 shared
+ across every app on the box and a per-occurrence timer would need the
+ materialised occurrences Β§2.3 refuses. A due time past the 400-day
+ horizon is not registered; `NextDue` still answers it, and the next
+ mutation inside the horizon re-asserts.
+- **The drain runs on boot, on every tick, and opportunistically on any
+ request** when `MIN(due_at) ≀ now` (checked at most once a minute via an
+ in-memory stamp). Three entrances, one function, all idempotent. Boot
+ covers the activator's wake; the tick covers the registry timer; the
+ request path covers a team that is simply using the app while a reminder
+ comes due.
+- **The drain works inside the request and returns 204.** Fifty SMTP sends
+ at 200 ms is ten seconds, inside the platform's timeout. Past a hundred
+ due rows it drains a page, re-asserts the timer for *now*, and returns β€”
+ the platform re-ticks and it continues. 5xx on a mailer failure so the
+ platform retries; the queue rows are the real retry state.
+
+Off platform (`ErrNotOnCarlos`), the request-path drain is the only
+entrance and `run-dev.sh` says so. Tests fake the control socket.
+
+### 5.2 Idempotency β€” at-least-once with a claim, and a deterministic Message-ID
+
+Which is worse for a reminder: fired twice or never fired? Never. A
+reminder that does not fire is the product failing at its one job; a
+duplicate is a nuisance. So:
+
+1. In a transaction, select due rows `WHERE fired_at IS NULL AND (claimed_at
+ IS NULL OR claimed_at < now βˆ’ 5 min)` and set `claimed_at = now,
+ attempts += 1`. The claim is the lease.
+2. Send, **with `Message-ID: <{reminder_id}.{occurrence_key_hash}@{host}>`**
+ β€” deterministic per occurrence. A crash between send and mark yields a
+ second send with the same Message-ID, which every mail client threads
+ or drops as a duplicate. That is the entire duplicate story, and it
+ costs one header.
+3. Set `fired_at`, record `late_by_sec = now βˆ’ due_at`, enqueue the rule's
+ next occurrence, re-assert the timer.
+
+A row that reaches five attempts stops and shows on the person's settings
+page as "could not deliver" with the occurrence β€” visible failure, never
+silent. `carlos.TickOccurrence` is not our dedupe key (the queue rows are),
+but the handler reads it and skips an occurrence it has already drained, so
+a platform redelivery is a no-op rather than a second scan.
+
+**Lateness is visible.** The Today page shows a reminder that fired more
+than ten minutes late as "reminded late (instance was asleep)". The house
+rule is to say the true thing; an instance that hibernates through a
+reminder should say so rather than let the person conclude they missed an
+email.
+
+### 5.3 Reminders are per person
+
+Every calendar gets this wrong because ICS `VALARM` lives on the event, and
+the event is shared. On a team calendar, "remind me" is about *me*. So a
+reminder row has a `member_id`, defaults come from the member's settings
+and are applied to the creator at event creation, and each other member can
+add their own from the event page. Import attaches `VALARM`s to the
+importing member; export emits only the exporting member's.
+
+Who is reminded of what, by default: the creator, of what they create.
+Nobody else, until attendees exist (Β§7). A team calendar where everyone is
+reminded of everything is a team calendar everyone mutes.
+
+### 5.4 Channel
+
+**Email, tonight, plain text, one line of subject.** `rastrillo/mail` is the
+one outbound surface; a production instance with no mailer refuses to boot
+(Docs' fail-closed shape). Subject: "Standup in 10 minutes". Body: the time
+in the recipient's zone, the location, the link. No HTML.
+
+Free with it: the **Today page lists reminders due in the next hour** as a
+callout β€” a scriptless in-app reminder that costs nothing. Web push
+(service worker, VAPID, script-only by nature) is deferred with a trigger
+(Β§7).
+
+---
+
+## 6. ICAL
+
+### 6.1 Import
+
+`POST /import` with a file, **synchronous**, 10 MB cap, parsed in the
+request, nothing persisted but the events. Sheets runs XLSX import as a
+background job with a status page; a 5 MB `.ics` parses in well under a
+second, the rastrillo `jobs` package is in-memory and a deploy ends it, and
+a blob store is a whole surface (Β§2.3 of Sheets) to add for a file we do not
+need to keep. A large-file job is deferred with a trigger (Β§7).
+
+Sharp edges, each with a decision:
+
+- **`TZID` β†’ IANA.** Three tiers: an IANA name verbatim; a Windows name
+ (`W. Europe Standard Time`) via an embedded CLDR `windowsZones` table
+ (~140 rows); a Mozilla/Outlook path (`/mozilla.org/…/Europe/Dublin`) by
+ extracting the trailing IANA segment. Anything else: **imported as
+ floating with a per-file notice** β€” "N events had a time zone Calendar
+ did not recognise and were imported as floating; open one to pin it".
+ The `VTIMEZONE` blocks themselves are not used to *resolve* times; our
+ tzdata is the authority, and a file's `VTIMEZONE` is routinely years
+ stale.
+- **UID.** Preserved, sealed, hashed. **Re-importing the same UID updates
+ the event if the incoming `SEQUENCE` is higher, else skips** β€” so
+ importing an exported file twice does not duplicate. Our own UIDs are
+ `<ulid>@<instance-host>` and are never regenerated.
+- **`RECURRENCE-ID` β†’ override rows. `EXDATE` β†’ `exdates`. `RDATE` β†’ an
+ override row whose `recurrence_id` the rule does not generate**, which
+ the recurrence set renders as an addition (Β§2.3 step 4). Export of that
+ case is imperfect (an `RDATE` plus a `RECURRENCE-ID` for it); recorded
+ in the deferred table rather than over-designed.
+- **All-day `DTEND` is exclusive** and the biggest single source of
+ off-by-one in the wild. One conversion function, one test, and a fixture
+ from each of five real exporters.
+- **`VALARM`** β†’ reminders for the importing member (Β§5.3). `ATTENDEE` β†’
+ counted and dropped tonight, with the count in the notice.
+- **`DTSTAMP`, `LAST-MODIFIED`, `SEQUENCE`** β†’ `updated_at`, `sequence`.
+ `CREATED` β†’ `created_at` if present.
+- **Line folding, `\,` `\;` `\n` escapes, `X-` properties** β€” folded lines
+ are the first parser bug everyone ships. Unknown `X-` properties are
+ dropped, not stored; storing them means round-tripping them, and that is
+ a corpus commitment we are not making tonight.
+
+### 6.2 Export
+
+`GET /calendars/{id}.ics` and `GET /events/{id}.ics`, behind the session.
+`PRODID:-//Oficina//Calendar//EN`, `VERSION:2.0`, `X-WR-CALNAME` from the
+sealed name, `X-WR-TIMEZONE` from the team zone.
+
+**Tonight: IANA `TZID`s and no `VTIMEZONE` components.** RFC 5545 Β§3.6 says
+`VTIMEZONE` MUST be present for every `TZID`; Google, Apple, Fastmail and
+Thunderbird all resolve IANA `TZID`s without one; Outlook desktop does not
+reliably. Generating `VTIMEZONE` from Go's `time.Location` means probing
+transitions (Go does not export them) β€” ~120 lines and an evening of edge
+cases (Lord Howe's 30-minute DST, Kolkata's none). It is the kind of thing
+that eats an overnight, so it is deferred with a concrete trigger (Β§7) and
+the export is honestly labelled non-conformant in the README until then.
+
+`UNTIL` always in `Z`. Overrides export with `RECURRENCE-ID;TZID=…`. Only the
+exporting member's `VALARM`s, and none at all on a feed unless `?alarms=1`.
+
+### 6.3 The round-trip corpus is a gate
+
+Import β†’ export β†’ import must be stable on our own files, and our export
+must import cleanly into Google, Apple and Thunderbird (checked by hand on
+the canary the first morning, then by fixture). Foreign fixtures: one each
+from Google Calendar, Apple Calendar, Outlook, Thunderbird and Fastmail β€”
+real exports, not hand-written.
+
+### 6.4 The subscription feed, and what it means for the one rule
+
+**Yes: `GET /feed/{token}.ics`**, a capability URL outside the session
+group, exactly Sheets' `/l/{token}`. Token hashed at rest. Per person and
+per calendar, created and revoked from the sharing page. `ETag` and
+`Last-Modified` from the calendar's max `updated_at`, `304` on match.
+
+What it means, said plainly:
+
+- **It does not break the rule.** The live instance decrypts for a
+ capability holder; the bucket still holds ciphertext; the token at rest
+ is a hash and not a capability.
+- **It moves the plaintext somewhere else.** The subscriber's Google or
+ Apple account now holds a full plaintext copy and refreshes it. The
+ sharing UI says this in words before the URL is shown β€” the same shape as
+ Sheets Β§9.5's "a guest grant discloses the team name".
+- **It is a hibernation anchor, and the cost is ours.** Apple polls
+ subscribed calendars as often as every 15 minutes; Thunderbird every 30;
+ Google roughly daily. Each poll wakes the instance. Three Apple
+ subscribers means the instance never sleeps. The `304` is cheap but the
+ wake is not free. `last_polled_at` and `poll_count` are shown beside
+ each feed on the sharing page β€” "polled 61 times this week" is
+ information a person can act on.
+
+Importing *from* an external feed (holidays, a sports fixture list) needs a
+declared schedule (`carlos schedule set -every 6h -path /jobs/feeds`) and a
+poller; deferred with a trigger (Β§7).
+
+---
+
+## 7. Scope β€” the maximally viable cut
+
+What one overnight session can finish to a deployed, tested standard,
+starting from Sheets' skeleton to copy: roughly 3,500 lines of Go and
+templates. The items below are ordered so that stopping after any line
+leaves a coherent product. Gates come with the item they guard.
+
+### Ships tonight
+
+1. **Skeleton.** `cmd/calendar`, chi, GORM over `modernc.org/sqlite`,
+ additive migrations, `seal` lifted from Sheets with the new label,
+ magic-link sign-in with `$CALENDAR_ALLOWED_ADDRESSES` (Docs' shape),
+ `/mode` cookie with three states, `rst-*` attribute grammar on
+ v0.23.0, `estilo` theme, CSRF app-wide, catalog keys for twelve
+ locales (month and weekday names and the date pattern are keys β€”
+ there is no CLDR dependency), 300-line caps on Go and JS. **Gates from
+ here:** plaintext grep, no-content-in-logs, two-member 404-not-403
+ isolation, scriptless-affordance diff, line caps.
+2. **Calendars and grants.** One team calendar created with the instance;
+ personal calendars per member, private by default; share to a member as
+ viewer or editor. Colour as OKLCH intent through Sheets' shim.
+3. **Events.** Create, edit, delete. Timed, all-day; zone from the member
+ preference; title, location, notes sealed. Ruling 1 storage. **Gate:**
+ the re-derive test (Β§3.1).
+4. **Views, all scriptless.** Today (default), Day, Week with server-packed
+ overlaps (Β§8.1), Month as a `<table>` with per-day chips, Agenda (next
+ fourteen days). Prev/next/today as links. Every empty slot is a link to
+ a prefilled `/events/new`. 320 px: month reflows to a stacked day list
+ in CSS; week claims the two-dimensional exemption and scrolls; day and
+ agenda reflow natively.
+5. **Recurrence subset, exceptions, split.** Expander with the rrule-go
+ oracle. `scope=this|following|all` on edit and delete. **Gate:** golden
+ vectors, and "editing this occurrence to 10:00 moves its reminder to
+ 09:50".
+6. **Reminders.** Rules, queue, `NextDue`, the one named timer, three
+ drain entrances, claim-lease, deterministic Message-ID, plain-text email,
+ the Today callout. **Gate:** kill-between-every-line test with a faked
+ control socket; the canary verifies a real wake the first morning.
+7. **ICS.** Import (synchronous), export per calendar and per event,
+ subscription feed with hashed token and `304`. **Gate:** round-trip
+ corpus on our own files plus the five foreign fixtures.
+8. **Two JS enhancements, both under 300 lines.** rastrillo's `datetime.js`
+ on every date field (free), and `create.js`: pointer-drag across empty
+ slots in day and week navigates to `/events/new?start=…&end=…`. Nothing
+ else. **There is no JSON route tonight, because no enhancement needs
+ one.**
+9. **Deploy** with `carlos deploy`, verify `GET /api/version` on the
+ process, not the edge.
+
+Honest risk ordering: ICS parsing edge cases and week-view packing are the
+two items most likely to overrun. If the night is short, item 7 ships
+import and export and defers the feed to the first morning β€” a calendar
+without a feed is still a calendar; a calendar with a broken parser is a
+calendar that eats files.
+
+### Deferred, each with its trigger
+
+| Not built | Trigger |
+|---|---|
+| **Attendees and invitations** (`attendees` table, `ATTENDEE`/`ORGANIZER`, iTIP `REQUEST`/`REPLY` by email) | The first time someone on the canary wants a person *outside the members list* at an event. This needs the home's guest contract and the assertion helper, which is why it is not tonight. |
+| **Guests** (per-calendar grants to non-members) | Same trigger; same dependency. |
+| **Web push reminders** | A member says a reminder reached email too late to help. Needs a service worker and VAPID keys, script-only by nature β€” the scriptless floor is the Today callout. |
+| **External feed subscription** (holidays, fixtures) | A member asks for public holidays. Needs a declared schedule and a poller; `carlos schedule set -every 6h`. |
+| **`VTIMEZONE` emission** | An Outlook desktop import of our export shows a wrong time. Until then the README says the export omits it and names who resolves IANA `TZID`s without one. |
+| **Expanding foreign RRULEs** | An imported file carries a rule outside the subset *and* the person needs its future occurrences shown. Vendoring rrule-go at runtime is the likely answer. |
+| **Drag to move and resize** | Paul's first canary review says moving an event by form is painful. The menu equivalent (the form) exists first, per the house rule. |
+| **Skip-ahead expansion** | A month render exceeds 50 ms on a canary profile with a real team's data. |
+| **Sealed instants with an in-memory index** | A design exists for `NextDue` that does not need plaintext due times β€” a sealed index the boot path can open before any request. Until then Β§1 stands. |
+| **Zone and week-start as home display preferences** | A person in two teams complains they set them twice. Same trigger as theme in the home spec. |
+| **Search** (titles, via decrypt-and-scan at dogfood N) | Asked for once. Full-text has Docs' FTS5 problem and no design. |
+| **Background import job for large files** | An import exceeds the request timeout. |
+| **`RDATE` round-trip exactness** | The corpus shows an `RDATE` file coming back different. |
+| **Working-hours shading, free/busy across members, per-event colours, attachments, print, natural-language beyond `datetime.js`, "find a time"** | Each: asked for, by a person on the canary, in the discussion. Listed so their absence is a decision. |
+| **Anything with "AI" in it** | β€” |
+
+---
+
+## 8. Routes
+
+Sheets' style: session-guarded groups, `/api` only for JSON, capabilities
+outside the session. Every row is scriptless unless the last column says
+otherwise.
+
+| Method | Path | Scriptless | Notes |
+|---|---|---|---|
+| GET | `/` | yes | β†’ `/today` when signed in; sign-in page otherwise |
+| GET | `/signin` | yes | |
+| POST | `/signin` | yes | allowlist-gated before the mail is sent; same 303 either way |
+| GET | `/auth/verify` | yes | magic-link redemption |
+| POST | `/signout` | yes | |
+| POST | `/mode` | yes | light/dark/auto cookie, returns to `return=` |
+| POST | `/_locale` | yes | mounted by the framework |
+| GET | `/static/*` | β€” | fingerprinted assets |
+| GET | `/feed/{token}.ics` | yes | **capability; outside the session.** `ETag`/`304`. `?alarms=1` |
+| POST | `/jobs/reminders` | β€” | **platform tick; `carlos.Tick` guarded.** Drains the queue inside the request; 204 / 5xx |
+| GET | `/api/next-due` | β€” | mounted by the framework from `Options.NextDue` |
+| GET | `/today` | yes | the front door β€” Β§8.1 |
+| GET | `/day/{yyyy-mm-dd}` | yes | one packed column + the day's list |
+| GET | `/week/{yyyy-mm-dd}` | yes | seven packed columns; the date is any day in the week |
+| GET | `/month/{yyyy-mm}` | yes | `<table>`; `+N more` links to the day |
+| GET | `/agenda` | yes | next fourteen days as a list; `?from=` |
+| GET | `/calendars` | yes | list, colours, archive |
+| POST | `/calendars` | yes | create |
+| GET | `/calendars/{id}` | yes | settings: name, colour, zone note |
+| POST | `/calendars/{id}` | yes | rename, recolour |
+| POST | `/calendars/{id}/archive` | yes | |
+| GET | `/calendars/{id}/share` | yes | grants and feeds, with the poll counts and the plaintext-elsewhere notice |
+| POST | `/calendars/{id}/share` | yes | grant a member |
+| POST | `/calendars/{id}/share/revoke` | yes | |
+| POST | `/calendars/{id}/feeds` | yes | mint a feed; shows the URL once |
+| POST | `/calendars/{id}/feeds/revoke` | yes | |
+| GET | `/calendars/{id}.ics` | yes | full export for the session member |
+| GET | `/events/new` | yes | `?start=&end=&date=&calendar=` prefill; `create.js` navigates here |
+| POST | `/events` | yes | create; kind radio; recurrence picker |
+| GET | `/events/{id}` | yes | single, or a master's page (shows the rule and the exceptions) |
+| GET | `/events/{id}/{yyyymmddThhmm}` | yes | **one occurrence** of a series |
+| POST | `/events/{id}` | yes | edit; `scope=this\|following\|all` on a series or occurrence |
+| POST | `/events/{id}/delete` | yes | same `scope` radio; "just this one" is the default |
+| POST | `/events/{id}/reminders` | yes | add my reminder |
+| POST | `/events/{id}/reminders/remove` | yes | |
+| GET | `/events/{id}.ics` | yes | one VEVENT plus its overrides |
+| GET | `/import` | yes | upload form |
+| POST | `/import` | yes | synchronous; redirects to a notice with counts |
+| GET | `/settings` | yes | zone, week start, default reminders, undeliverable reminders |
+| POST | `/settings` | yes | |
+| GET | `/healthz` | β€” | |
+
+**Enhanced (script adds, floor remains):** `/events/new` and the edit form
+get `datetime.js`; `/day` and `/week` get `create.js`. That is the entire
+enhanced surface tonight. The scriptless-affordance diff gate fetches every
+page script-stripped and asserts no affordance is missing.
+
+---
+
+## 9. What makes it feel considered
+
+### 9.1 Today is the front door, and it says how full the day is in a sentence
+
+The question a person opens a calendar to answer is "what's next", not
+"what does September look like". So `/` is Today: this day's list, then the
+rest of the week as a list, with the month one link away.
+
+At the top, one sentence, computed server-side, in the person's locale:
+
+> *Wednesday. Four things; the first at 09:30, the last ends at 17:00.
+> Two hours and fifteen minutes unbooked between them.*
+
+And when there is nothing left to give:
+
+> *There is no unbooked hour today.*
+
+That sentence is the product's whole opinion about overfull days. It
+states, never nags, never colours the day red. Colour never carries
+meaning alone, and this carries it in words, which is stronger anyway.
+Booked time counts meetings and commitments; notes do not count (Β§9.2).
+
+### 9.2 A meeting, a commitment and a note are three different things
+
+Most calendar bloat comes from one event type pretending to be all three.
+`kind` is one radio group at creation:
+
+- **Meeting** β€” has, or will have, other people. Gets the default
+ reminder. Counts as booked.
+- **Commitment** β€” a promise to yourself: a focus block, the gym, "write
+ the spec". No reminder by default (you know), counts as booked, drawn
+ with an outline rather than a fill so it reads differently at a glance
+ *and* in greyscale.
+- **Note** β€” a fact about the day, not a claim on it: "Anna on leave",
+ "release day". All-day by nature, never counts as busy, never reminded,
+ renders as a line above the grid.
+
+Exports map to `X-OFICINA-KIND` and `TRANSP:TRANSPARENT` for notes, so the
+distinction survives a round trip and other calendars at least see notes
+as free time.
+
+### 9.3 The past is written in the past tense
+
+Agenda and Today render a future item as "09:00–09:30 Standup" and a past
+one as "Standup, ended 09:30". Past items drop to muted weight. No strike,
+no colour change, and the tense carries it for a screen reader too. It is
+free and it is the difference between a list of times and a day.
+
+### 9.4 No spanning bars in month view
+
+A multi-day event repeats as one chip per day with `β†’`/`←` continuation
+markers. Spanning bars are the largest single source of layout complexity
+in every calendar ever built, they break at week boundaries anyway, and the
+information they carry β€” "this continues" β€” is a glyph. A month is for
+scanning density, not reading; the day and week views are for reading.
+This ships scriptless and stays that way even with JS.
+
+### 9.5 One question on deleting a recurring event, and "all" is not the default
+
+*Just this one Β· This and the ones after it Β· All of them.* "Just this one"
+is selected. "All of them" is last and is not a red button; it is the same
+button with the count beside it: "All 52 of them". The number is the
+warning.
+
+### 9.6 Your zone is stated; the other zone is a whisper
+
+Every time on every page is the viewer's. When an event was authored
+elsewhere, "09:00 Dublin" sits beside it in secondary text, and only then.
+No `(GMT+1)` anywhere, ever.
+
+---
+
+## 10. The things everyone gets wrong, and what we do from commit one
+
+1. **Storing UTC as truth.** Future zoned events drift when a government
+ changes the rules. Ruling 1: wall-clock plus zone is the record; the
+ instant is an index (Β§3.1).
+2. **All-day events as midnight-to-midnight timed events.** They land on
+ the wrong day for anyone in another zone β€” the "birthday on the wrong
+ date" bug. All-day is a third kind with no instant (Β§3.2).
+3. **Exclusive `DTEND` on all-day events**, shown inclusive in the UI. One
+ conversion function, one test, five real fixtures (Β§6.1).
+4. **Sniffing the browser's zone and silently rewriting the view.** Stated
+ preference; a notice, never a rewrite (Β§3.3).
+5. **Materialising occurrences.** Then "edit all" is a migration and
+ "forever" is a table that grows. Occurrences are computed per window,
+ overrides are rows, exdates are a column (Β§2.3).
+6. **Querying overrides by their original time.** An occurrence dragged
+ into the visible week from outside it vanishes. Query by the new time
+ (Β§2.3 step 4).
+7. **Orphaning overrides** when the master is deleted or its rule shortened.
+ Same transaction, always (Β§2.2).
+8. **`COUNT` after `EXDATE`.** COUNT counts generated instances before
+ exclusion; a test pins it.
+9. **`UNTIL` local versus UTC.** Lenient in, strict out (Β§3.5).
+10. **The 31st, the fifth Friday, and February 29.** Skip, per RFC Β§3.3.10;
+ never clamp, never crash. The copy says "in years that have one" for
+ the leap-day case, because Google does the other thing and someone
+ will ask.
+11. **A reminder system that assumes a live process.** Queue plus
+ `NextDue` plus one platform timer plus three drain entrances, work
+ inside the request (Β§5.1).
+12. **Duplicate reminders after a crash, with no idempotency key.** Claim
+ lease plus deterministic `Message-ID` (Β§5.2). And its mirror: a
+ reminder that never fires and nobody knows β€” `late_by_sec`, the
+ "could not deliver" list, and the "reminded late" notice.
+13. **Reminders as a property of the event on a shared calendar.** Per
+ person (Β§5.3).
+14. **`+N more` that opens nothing without script.** It is a link to the
+ day (Β§8).
+15. **Week-view geometry as the only carrier of duration.** Every chip
+ prints its times as text; the absolute positioning is decoration on an
+ ordered list (Β§8.1).
+16. **"Today" computed in the server's zone.** The viewer's, always (Β§3.4).
+17. **Calendar colour as the only encoding of which calendar an event is
+ on.** Every chip carries the calendar name in its accessible name, and
+ commitments are outlined rather than filled (Β§9.2).
+18. **A subscription feed treated as free.** It is a hibernation anchor
+ and a plaintext copy elsewhere; both are said in the sharing UI (Β§6.4).
+19. **Content in error strings.** An import error says "line 412: DTEND
+ before DTSTART", never the summary on line 410. Test-enforced.
+20. **Fifteen-minute time steps.** They hide the 09:10 meeting. Step is
+ five; `datetime.js` accepts anything.
+
+---
+
+## 11. The scriptless layout, concretely
+
+Called out separately because it is the constraint the user most wants
+answered, and because it is easier than it looks once the server does the
+geometry.
+
+**Month** β€” a `<table>`: `<thead>` of weekday names (catalog keys, in the
+member's week-start order), one `<tr>` per week, one `<td>` per day with a
+date heading that links to `/day/…`, a `+` link to `/events/new?date=…`, an
+`<ol>` of at most four chips (each an `<a>` to the event or occurrence) and
+a `+N more` link to the day. Today's cell carries `aria-current="date"` and
+a text marker, not only a tint. Days outside the month are muted and still
+real. At 320 px, CSS turns rows and cells into blocks with the date as a
+heading and empty days collapsed β€” the month becomes a list, in CSS only.
+
+**Week and day** β€” an all-day strip (a plain list), then seven `<div>`
+columns each `position: relative` at 24 Γ— hour-height, each holding an
+`<ol>` of chips positioned by inline `style="top:…%;height:…%;left:…%;
+width:…%"` (rastrillo's default CSP allows inline style attributes β€” its
+own partials use them). **The server runs the overlap packer:** cluster
+events that transitively overlap, assign each a lane by earliest start,
+width is `100%/lanes` and left is `lane Γ— width`. Eighty lines of Go, unit
+tested on the classic three-way overlap. DOM order is by day then start,
+which is the reading order a screen reader wants; every chip prints its
+times in text. Beneath the chips, 48 half-hour slot links per column with
+`rst-sr-only` labels ("New event, Wednesday 09:30") and 24 px hit areas β€”
+that is click-to-create with no script, and `create.js` turns a drag across
+them into one navigation with a start and an end.
+
+**Agenda** β€” a list grouped by day heading. The best mobile view and the
+best scriptless view, and the shape Today is built from.
+
+**Forms** β€” rastrillo's `field-datetime` (native `datetime-local`,
+step 300) and `field-daterange`; the recurrence picker is a `<select>` for
+the frequency, a checkbox row for weekdays, a radio for "on day N / on the
+Nth weekday", a number for the interval, and a radio for the end β€” all
+plain controls. The all-day checkbox swaps `datetime` fields for `date`
+fields on the next render, and `datetime.js` does it live.
+
+---
+
+## 12. Rulings this brainstorm asks Paul to make
+
+Proposed for the spec's Β§1 table, each with its rejected alternative:
+
+| # | Proposed ruling | Rejected |
+|---|---|---|
+| 1 | **Store what the person said: wall-clock plus IANA zone is the record; the instant is a derived index** (Β§3.1) | UTC as truth; storing both as peers |
+| 2 | **Sealed at rest for content; times, zones and rules plaintext and published** (Β§1, Β§4.3) | Sealed instants with an in-memory index (deferred, not rejected) |
+| 3 | **Recurrence subset is Google's picker; hand-rolled expander; rrule-go as test oracle; foreign rules stored, shown once, never refused** (Β§2.1) | rrule-go at runtime; refusing foreign rules |
+| 4 | **Occurrences are computed, never rows; overrides are rows; deletions are `EXDATE`; "following" is a split** (Β§2.2–2.3) | Materialising; tombstone rows; `RANGE=THISANDFUTURE` |
+| 5 | **One reminder queue, one `NextDue`, one named platform timer, three drain entrances, work inside the request** (Β§5.1) | A timer per reminder (host cap, materialisation); a ticker |
+| 6 | **At-least-once with a claim lease and a deterministic `Message-ID`; lateness recorded and shown** (Β§5.2) | Mark-then-send (loses reminders); exactly-once claims |
+| 7 | **Reminders are per person** (Β§5.3) | Per event |
+| 8 | **Email is the only channel tonight; the Today callout is the scriptless in-app reminder** (Β§5.4) | Web push first |
+| 9 | **The viewer's zone is a stated preference, never sniffed** (Β§3.3) | Browser detection with silent update |
+| 10 | **Floating events are import/export only** (Β§3.2) | Offering them in the UI |
+| 11 | **Subscription feeds ship as capability URLs, per person, with the plaintext-elsewhere and hibernation costs stated in the UI** (Β§6.4) | No feeds; feeds without the disclosure |
+| 12 | **Export emits IANA `TZID`s without `VTIMEZONE` tonight, labelled non-conformant** (Β§6.2) | Generating `VTIMEZONE` tonight; refusing to export |
+| 13 | **Three kinds: meeting, commitment, note** (Β§9.2) | One event type with flags |
+| 14 | **Today is the front door, with the day's shape in one sentence** (Β§9.1) | Month grid as landing |
+| 15 | **No spanning bars in month view, with or without script** (Β§9.4) | Bars as the JS enhancement |
+| 16 | **Sign in locally by magic link and allowlist tonight; adopt the home's assertion when the helper exists** (Β§0) | Blocking on the helper |
+| 17 | **Import is synchronous, 10 MB, nothing persisted but events** (Β§6.1) | A background job and a sealed blob store |
+
+---
+
+## 13. Open
+
+- **How the activator wakes us from `NextDue`.** `serve.go` says it
+ "hibernates knowing when to wake it"; whether the wake is a bare process
+ start or a POST somewhere is in the platform spec
+ (`2026-08-23-scheduled-work-design.md Β§7`), which this session could not
+ read. The three-entrance drain is designed so that either answer works;
+ the first canary morning confirms which it is.
+- **Whether `carlos.ScheduleAt` is reachable from a hibernating exec child
+ at boot** β€” `schedule.go` says the socket is bound before spawn, so it
+ should be. Verified on the canary, not assumed.
+- **Locale month and weekday names versus `Intl`.** The scriptless render
+ uses catalog keys; `datetime.js` uses `Intl`. A browser-drive gate that
+ compares them per locale would catch a divergence; whether it is worth an
+ overnight hour is Paul's call.
+- **The suite's own domain** β€” inherited from Sheets Β§15; the feed URL is
+ the string most expensive to change later after the RP host.