# 🤖 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.
