oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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

// 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:

  1. 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.
  2. 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).
  3. 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 VALARMs 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 TZIDs and no VTIMEZONE components. RFC 5545 Β§3.6 says VTIMEZONE MUST be present for every TZID; Google, Apple, Fastmail and Thunderbird all resolve IANA TZIDs 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 VALARMs, 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 TZIDs 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 TZIDs 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.