oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Calendar β€” brainstorm
2
3**Status:** brainstorm, 2026-09-02. Raw material for the design spec, not the
4spec. Every "ruled" below is a *proposal* for Paul; nothing here is settled
5until it lands in the spec's Β§1 table with his name beside it.
6**Scope:** the Calendar instance app, beside Sheets and Docs under Oficina.
7Where a decision is already made over there, this document consumes it and
8says so rather than restating it.
9
10The user's floor for tonight: **calendar layout, adding an event, reminders,
11ICAL**, deployed. Everything else here is either argued into the overnight
12cut with a reason, or deferred with a written trigger.
13
14---
15
16## 0. What was read, and the three facts that shaped this
17
18Read in full: `sheets-core/AGENTS.md`, `sheets-core/PRODUCT.md`,
19`home/AGENTS.md`, `docs/AGENTS.md`, `estilo/AGENTS.md`, the Sheets design
20spec (all sections), Docs Β§2, Β§6.2, Β§7.3, Β§14–15, the home spec Β§0–3, and
21rastrillo v0.23.0's `SKILL.md`, `README.md`, `carlos/`, `mail/`, `serve.go`
22and `seal` (Sheets' copy).
23
24Three verified facts do most of the work in this document:
25
261. **The platform owns the clock, and offers two handles.**
27 `rastrillo.Options.NextDue func() time.Time` answers the activator's
28 `GET /api/next-due` poll before hibernation β€” the instance is woken at
29 that instant. `carlos.ScheduleAt(ctx, name, at, path)` registers a
30 one-shot timer in the *box's* registry (survives restarts, upsert by
31 name, `^[a-z0-9][a-z0-9-]{0,31}$`, **400-day horizon, 1000 pending per
32 host**) and the platform POSTs to `path` when due. Ticks are
33 at-least-once, guarded by `carlos.Tick(r)`, and **the work must finish
34 inside the request** β€” 202-plus-goroutine hibernates mid-job. Off
35 platform both degrade to `ErrNotOnCarlos`. (`carlos/carlos.go`,
36 `carlos/schedule.go`, `serve.go:122–130`.)
372. **The framework already ships the scriptless date input.**
38 `field-date`, `field-time`, `field-datetime`, `field-daterange` partials,
39 `form.Range(p, "starts", "ends")`, and `datetime.js` β€” a combobox reading
40 "next fri 9am" in the page's own locale, vocabulary from the catalog,
41 month names from `Intl`. Quick-add is mostly free. (`SKILL.md:185–195`.)
423. **The home's assertion helper is still an open gap** (Sheets Β§15, Docs
43 Β§15.4). Docs signs in with a local magic link plus `$DOCS_ALLOWED_ADDRESSES`.
44 Calendar does the same tonight and migrates when the helper lands.
45
46---
47
48## 1. The one rule
49
50Three candidates, then the argument.
51
52### Candidate A β€” sealed at rest, the Sheets shape
53
54> **An event's contents are sealed at rest. The bucket never holds a
55> plaintext title, place, note or name.**
56
57The falsifiable form: **publish this database in full and the worst anyone
58learns is the shape of the team's time β€” when they are busy, for how long,
59how often, and in which time zones β€” and who is on the team. Never what any
60event is, where it is, or what was written about it.**
61
62Test from the first commit: the plaintext grep over the raw SQLite file and
63WAL, seeded with a fixture calendar whose titles, locations and notes are
64distinctive strings. `plaintextExempt` starts empty.
65
66### Candidate B β€” store what the person said
67
68> **Every event is stored as the wall-clock time the person typed and the
69> zone they typed it in. The instant is derived, never authored.**
70
71Falsifiable: change the binary's embedded tzdata, run the re-derive sweep,
72and every event still reads the same wall-clock in its own zone. A test
73builds with a doctored zone, re-derives, and asserts `start_local` and `tz`
74are byte-identical while `start_utc` moved.
75
76### Candidate C β€” a reminder fires once and never silently late
77
78> **A reminder is delivered at least once, at most twice, and its lateness
79> is visible.**
80
81Falsifiable: kill the process at every line between claim and mark; restart;
82the occurrence is delivered, the duplicate (if any) carries the same
83`Message-ID`, and the queue row records how late it was.
84
85### The argument
86
87**A is the one rule.** It is the family's shape β€” Sheets, Docs and the home
88all open with a storage rule with a `publish the bucket and…` test β€” and it
89is the only one of the three that constrains *every* table from the first
90migration. B is the most important *correctness* decision in the app and
91belongs in the settled-decisions table as ruling 1, with its test as a
92gate. C is a testing gate, not a rule; it constrains one subsystem.
93
94**A is also harder to keep than Sheets' version, and the spec must say so.**
95A spreadsheet's plaintext shape β€” coordinates, sizes, edit timestamps β€” is
96nearly meaningless. A calendar's plaintext shape is *free/busy*, which
97companies sell as a product. "Busy every Tuesday 14:00–15:00, recurring
98weekly, reminder one day before" is a lot to know about a person. The
99honest leak list (Β§4.4) says this in words, and the README does too.
100
101**Why times stay plaintext anyway, tonight.** Three reasons, and the third
102is the one that ends the argument:
103
104- Every view is a range query. Month view is `start_utc < ? AND end_utc >
105 ?`; sealing instants means decrypting every row per page or holding a
106 decrypted index in memory.
107- `NextDue()` must answer from the database at boot, before any handle is
108 open, with `SELECT MIN(due_at) … WHERE fired_at IS NULL`.
109- **The platform's timer registry holds every reminder's fire instant in
110 plaintext regardless** (`ScheduleAt` sends `at` over the control socket
111 and the box keeps it). Sealing instants in our file while they sit
112 readable in the box's registry would be claiming a property the estate
113 does not have.
114
115So the rule forbids *content* at rest and admits *shape*, and says which is
116which. A sealed-instants design with an in-memory index and a `NextDue`
117that does not need plaintext is in the deferred table (Β§7) with a real
118trigger, because at dogfood N it is feasible and someone will ask.
119
120---
121
122## 2. Recurrence
123
124RRULE is a swamp because RFC 5545 Β§3.3.10 is a grammar for every rule
125anyone could want, and calendars need the six rules anyone ever uses.
126
127### 2.1 The subset that ships β€” and it is Google's UI, not a compromise
128
129Google Calendar's own repeat picker offers exactly: daily, weekly on chosen
130days, monthly on day N, monthly on the Nth weekday, yearly, and "custom"
131with an interval and an end. That is the subset:
132
133| Ships | Form |
134|---|---|
135| `FREQ=DAILY\|WEEKLY\|MONTHLY\|YEARLY` | β€” |
136| `INTERVAL=n` | every 2 weeks, every 3 months |
137| `BYDAY` for WEEKLY | `MO,WE,FR` |
138| `BYDAY` with one ordinal for MONTHLY | `2TU` (second Tuesday), `-1FR` (last Friday) |
139| `BYMONTHDAY` single value for MONTHLY | `15`, `-1` (last day) |
140| `COUNT=n` or `UNTIL=…`, or neither | forever is allowed |
141| `WKST` | honoured on import, `MO` on export |
142
143**Not in the subset:** `BYSETPOS`, `BYYEARDAY`, `BYWEEKNO`, `BYHOUR` and
144below, `HOURLY`/`MINUTELY`/`SECONDLY`, multiple `BYDAY` on MONTHLY,
145multiple `BYMONTH`, `RDATE` lists as a first-class thing.
146
147**Hand-roll the expander, ~250 lines, and use `teambition/rrule-go` as a
148test oracle only.** The expander's contract with our zone model (Β§3) is
149the thing that must be ours: it iterates in *wall-clock* in the event's
150zone and resolves each occurrence to an instant afterwards, so a 09:00
151weekly meeting stays 09:00 across a DST change. A runtime dependency would
152be doing the same arithmetic one abstraction away from our own rules. The
153oracle catches the cases where I am wrong about the RFC: MONTHLY on the
15431st (skip months without one β€” RFC says "MUST be ignored", not "clamp"),
155`-1FR` in a five-Friday month, `COUNT` interacting with `EXDATE` (COUNT
156counts generated instances *before* exclusion), Feb 29 yearly (skipped in
157non-leap years per RFC; Google shows Feb 28 and we do not β€” say so in the
158UI copy for that one case).
159
160**A foreign rule β€” one outside the subset β€” is imported, never refused.**
161It is stored verbatim in `rrule`, flagged `rrule_foreign=1`, the master
162renders once at its `DTSTART` with a marker "repeats on a rule Calendar
163cannot show yet", exports back byte-identical, and the series editor
164offers only "replace this rule" (with the subset picker). The count of
165foreign rules is logged on import; the rules themselves are not (a rule is
166structure, not content, but the line stays clean). Deferred: expanding
167foreign rules (Β§7).
168
169### 2.2 Exceptions and overrides β€” the RFC model, because it round-trips
170
171Three kinds of row in one `events` table:
172
173- **Single event** β€” `rrule=''`, `series_id NULL`.
174- **Series master** β€” `rrule!=''`, `series_id NULL`. Carries `exdates`
175 (deleted occurrences, as local wall-clock datetimes) and `until_utc`.
176- **Override** β€” `series_id = master.id`, `recurrence_id_local = the
177 original occurrence's wall-clock`, and a full copy of every editable
178 field. This is RFC 5545 `RECURRENCE-ID` exactly, so an override imports
179 and exports without translation.
180
181Deletion of one occurrence is an `EXDATE` on the master, **not a tombstone
182row** β€” a deletion is not content, the column is one line, and it exports
183exactly. Modification of one occurrence is an override row. An override
184that has no matching generated occurrence (an imported `RDATE`, or an
185occurrence the rule stopped generating after a rule edit) is still shown β€”
186it is an addition, and the recurrence-set algorithm below treats it as one.
187
188**"This and following" is a split, not a `RANGE=THISANDFUTURE`.** The
189master gets `UNTIL = the occurrence before`, a new master is created with
190`DTSTART = this occurrence` and the new values, and every override whose
191`recurrence_id β‰₯ split point` is re-parented in the same transaction. The
192tail gets a new UID. That is what Google and Apple do; `RANGE=THISANDFUTURE`
193is in the RFC and in almost no shipping implementation, and an export
194carrying it would be mis-read by the tools people actually use.
195
196**Editing "all" of a series with overrides:** the master's fields change;
197overrides keep their own values (that is what an override means); the
198question "also reset the N exceptions?" is asked, defaulting to no.
199
200**Deleting a series deletes its overrides in the same transaction.** Named
201because the orphan is the classic bug (Β§9.13).
202
203### 2.3 Expanding for a view without materialising infinity
204
205Occurrences are **never rows**. They are computed per request, inside a
206window `[view_start, view_end)` in the viewer's zone:
207
2081. **Timed singles and overrides** by their *actual* time:
209 `start_utc < view_end AND end_utc > view_start`.
2102. **All-day singles and overrides** by date text:
211 `start_local < 'view_end_date' AND end_local > 'view_start_date'`
212 (ISO dates compare lexically, and an all-day event has no instant).
2133. **Masters that could intersect:** `dtstart_utc < view_end AND (until_utc
214 IS NULL OR until_utc >= view_start)`. For each, the expander iterates
215 from `DTSTART`, discarding occurrences before the window, until it
216 passes the window or hits `COUNT`/`UNTIL`. A daily series ten years old
217 is ~3,650 cheap iterations; fine at dogfood N. A **hard cap of 1,000
218 generated occurrences per master per request** is the guard against a
219 hostile or broken rule; past it the series renders a marker and stops.
2204. **The recurrence set** = generated βˆ’ `exdates` βˆ’ occurrences that have an
221 override (matched on `recurrence_id_local`) + overrides from steps 1–2
222 (matched on their *new* time). Overrides are queried by where they
223 *moved to*, never by where they came from β€” querying by
224 `recurrence_id` misses an occurrence dragged into the window from
225 outside it, which is the second classic bug.
2265. **Occurrence duration is a wall-clock delta.** `end_local βˆ’ start_local`
227 as a calendar difference, applied to each occurrence's wall-clock start
228 β€” so a 09:00–10:00 meeting is 09:00–10:00 on both sides of a DST change,
229 which is what "till ten" means to a person.
230
231**An occurrence's address is `/events/{id}/{yyyymmddThhmm}`** β€” the master
232id plus the *original* occurrence's wall-clock. That URL is how a scriptless
233page opens "this Tuesday's standup"; the edit form on it carries a
234`scope=this|following|all` radio.
235
236Skip-ahead arithmetic (first occurrence β‰₯ window start in O(1) for
237DAILY/WEEKLY) is deferred with a measured trigger (Β§7).
238
239---
240
241## 3. Time zones
242
243This is where calendars die, and the reason is always the same: the app
244stored the answer (an instant) and threw away the question (what the person
245meant).
246
247### 3.1 Ruling 1 β€” store what the person said
248
249Every event row stores:
250
251- `start_local`, `end_local` β€” ISO 8601 wall-clock to the minute, no
252 offset: `2026-09-02T09:00`. For all-day events, dates: `2026-09-02`, end
253 **exclusive** (the RFC's rule; the UI shows inclusive and the conversion
254 happens in exactly one function with a test).
255- `tz` β€” an IANA name (`Europe/Dublin`), `''` for floating, ignored for
256 all-day.
257- `all_day` β€” the third kind, not a midnight-to-midnight timed event.
258- `start_utc`, `end_utc` β€” **derived** unix seconds, for indexes and range
259 queries only. Never edited, never trusted as intent.
260
261Go embeds `time/tzdata`; the binary stamps its tzdata version into a `meta`
262row at boot. If the stamp differs from the last boot, a **re-derive sweep**
263recomputes every `*_utc` column (a few thousand rows; log the count, never
264a title), re-queues every pending reminder (Β§5), and re-asserts the
265platform timer. A zone abolishing DST next year moves the instant of every
266future 09:00 meeting there and leaves `start_local` untouched, which is
267correct and is the whole point.
268
269### 3.2 The three kinds, and which the UI can make
270
271| Kind | RFC form | Stored | Rendered | UI can create? |
272|---|---|---|---|---|
273| **All-day** | `DTSTART;VALUE=DATE` | dates, no zone | on that date in every viewer's zone | yes |
274| **Zoned** | `DTSTART;TZID=…` | wall-clock + IANA zone | converted to the viewer's zone | yes β€” the default |
275| **Floating** | `DTSTART` bare | wall-clock, `tz=''` | at that wall-clock in *whatever* zone the viewer is in | **no** β€” import and export only |
276
277Floating is refused in the UI on purpose. On a *shared* calendar a floating
27809:00 is read as the same instant by two people in two zones, and it is
279not. It exists in the model because the RFC requires it and files contain
280it; a floating event renders with a marker ("floating β€” 09:00 wherever you
281are") and its edit form offers to pin it to a zone.
282
283### 3.3 The viewer's zone is stated, never sniffed
284
285Scriptless has no `Intl.DateTimeFormat().resolvedOptions().timeZone`, and
286that is a feature. A person's zone is a **preference on their member row**,
287defaulted from the instance's team zone at first sign-in, changed in
288settings. The one enhancement `datetime.js` may add is a *notice* β€” "your
289browser says America/New_York; you are set to Europe/Dublin. Switch?" β€” with
290a real form behind it. Never a silent rewrite. The "my whole calendar
291shifted eight hours when I landed" horror is a sniff.
292
293The team zone is `$CALENDAR_TZ` at provisioning, editable in settings. It
294is used for ICS `X-WR-TIMEZONE` and as the member default, nothing else.
295
296The zone is per person, not per team, so it belongs with the home's display
297preferences eventually β€” it fails cosmetically under a hostile value, which
298is the home spec's test for a claim riding an assertion. Deferred with the
299same trigger as theme (Β§7).
300
301### 3.4 Rendering across zones
302
303- **The primary time is always the viewer's.** An event authored in Dublin
304 by a Dubliner and viewed from Lisbon shows at 08:00, with "09:00 Dublin"
305 as secondary text β€” only when the zones differ. Never a `(GMT+1)` suffix
306 on everything.
307- **An event that crosses the viewer's midnight is split into two chips**
308 in day and week grids, server-side, with continuation markers `β†’` and
309 `←` that carry `aria-label` text. The event's own date is irrelevant to
310 where it renders; the viewer's date governs.
311- **"Today" is computed in the viewer's zone.** A server in UTC deciding
312 what "today" is for someone in Auckland is bug Β§9.16.
313
314### 3.5 Gaps and folds
315
31601:30 on a spring-forward day does not exist; on a fall-back day it exists
317twice. `time.Date` in the location normalises; Go documents that the choice
318"is not guaranteed". We accept Go's choice, **pin it with a test so it is
319known rather than assumed**, and do not pretend to do better. It affects one
320hour a year between 01:00 and 03:00.
321
322`UNTIL` must be UTC when `DTSTART` is zoned (RFC), and half the exporters in
323the wild write it local. **Lenient on import** (a bare `UNTIL` is read in
324the event's zone), **strict on export** (always `Z`).
325
326---
327
328## 4. Data model
329
330### 4.1 Tables
331
332```go
333// Member β€” who may hold a session. Address plaintext by the home spec's
334// ruling 5 (the sharer typed it; the team already knows it).
335type Member struct {
336 ID uint64 `gorm:"primaryKey"`
337 Address string `gorm:"uniqueIndex"`
338 PersonRef string // from the home, once the contract is consumed; '' until then
339 NameSealed []byte // seal purpose "member.name"
340 TZ string // IANA; defaults to the team zone
341 WeekStart int // 1 = Monday (default), 7 = Sunday, 6 = Saturday
342 DefaultReminderMin int // applied to timed events they create; 10
343 DefaultAllDayMin int // applied to all-day events; 15*60 (09:00 the day before)
344 CreatedAt time.Time
345}
346
347// Calendar β€” a container. One "team" calendar per instance, personal ones
348// per member. Colour is an OKLCH intent, as Sheets' fills are.
349type Calendar struct {
350 ID uint64 `gorm:"primaryKey"`
351 NameSealed []byte // seal purpose "calendar.name"
352 Kind string // "team" | "personal"
353 OwnerID uint64
354 HueIntent float64
355 ChromaIntent float64
356 CreatedAt time.Time
357 ArchivedAt *time.Time `gorm:"index"`
358}
359
360// Grant β€” the hand-written grants join. Explicit rows even for the team
361// calendar (every member is granted editor on it at creation), so one
362// query shape serves every calendar and 404-not-403 has one home.
363type Grant struct {
364 ID uint64 `gorm:"primaryKey"`
365 CalendarID uint64 `gorm:"uniqueIndex:idx_grant_cal_member"`
366 MemberID uint64 `gorm:"uniqueIndex:idx_grant_cal_member"`
367 Role string // "owner" | "editor" | "viewer"
368}
369
370// Event β€” single, series master, or override (see Β§2.2).
371type Event struct {
372 ID uint64 `gorm:"primaryKey"`
373 CalendarID uint64 `gorm:"index"`
374
375 // Identity for ICS. The hash is the lookup key (re-import dedupes on
376 // it); the sealed copy is what exports. UIDs are sealed because some
377 // generators build them from the title.
378 UIDHash string `gorm:"index"`
379 UIDSealed []byte // seal purpose "event.uid"
380
381 // Series linkage. An override has both; a master has neither.
382 SeriesID *uint64 `gorm:"index"`
383 RecurrenceIDLocal string // "2026-09-02T09:00" β€” the ORIGINAL occurrence
384
385 TitleSealed []byte // "event.title"
386 LocationSealed []byte // "event.location"
387 NotesSealed []byte // "event.notes"
388
389 // Intent β€” ruling 1.
390 AllDay bool
391 StartLocal string // "2026-09-02T09:00" or "2026-09-02"
392 EndLocal string // exclusive for all-day
393 TZ string // IANA; "" = floating; ignored when AllDay
394
395 // Derived, index-only. Rebuilt by the re-derive sweep. Plaintext.
396 StartUTC int64 `gorm:"index"`
397 EndUTC int64 `gorm:"index"`
398
399 // Recurrence β€” plaintext structure, published in the README.
400 RRule string // "" for singles and overrides
401 RRuleForeign bool
402 UntilUTC *int64 `gorm:"index"`
403 Exdates string // comma-separated local datetimes
404
405 Kind string // "meeting" | "commitment" | "note" β€” Β§8.2
406 Status string // "confirmed" | "tentative" | "cancelled"
407 Sequence int // RFC SEQUENCE; bumped on every edit
408
409 CreatedBy uint64
410 CreatedAt time.Time
411 UpdatedAt time.Time // exported as DTSTAMP / LAST-MODIFIED
412 DeletedAt *time.Time `gorm:"index"`
413}
414
415// Reminder β€” the RULE, and it is per PERSON (Β§5.3). "Remind me 10 minutes
416// before" is a fact about me, not about the event.
417type Reminder struct {
418 ID uint64 `gorm:"primaryKey"`
419 EventID uint64 `gorm:"index"`
420 MemberID uint64 `gorm:"index"`
421 OffsetMin int // before start; negative = after
422 Channel string // "email" tonight
423}
424
425// ReminderQueue β€” the NEXT un-fired occurrence per rule, and only that.
426// Bounded by the number of rules, not by the length of any series.
427type ReminderQueue struct {
428 ID uint64 `gorm:"primaryKey"`
429 ReminderID uint64 `gorm:"uniqueIndex"` // at most one pending row per rule
430 OccurrenceKey string // "{event_id}/{recurrence_id_local}" β€” the idempotency key
431 DueAt int64 `gorm:"index"`
432 ClaimedAt *int64
433 FiredAt *int64
434 Attempts int
435 LateBySec int64 // recorded at fire; the honesty column
436}
437
438// Feed β€” a subscription capability. Token hashed, per the share-link
439// ruling. Per person: it exports that person's calendars and reminders.
440type Feed struct {
441 ID uint64 `gorm:"primaryKey"`
442 MemberID uint64 `gorm:"index"`
443 CalendarID uint64 `gorm:"index"`
444 TokenHash string `gorm:"uniqueIndex"`
445 CreatedAt time.Time
446 RevokedAt *time.Time
447 LastPolledAt *time.Time
448 PollCount int64
449}
450
451// Meta β€” key/value. "tzdata_version", "schema".
452type Meta struct {
453 Key string `gorm:"primaryKey"`
454 Value string
455}
456```
457
458**Not created tonight, designed so its arrival is additive:** `attendees`
459(`event_id`, `member_id NULL`, `address_hash`, `address_sealed`,
460`name_sealed`, `partstat`, `role`). Tonight's import counts `ATTENDEE` lines
461and says how many were dropped.
462
463### 4.2 Seal purposes
464
465`oficina/calendar:` + `event.title`, `event.location`, `event.notes`,
466`event.uid`, `calendar.name`, `member.name`. Lifted from Sheets' `seal`
467package unchanged except for the label prefix; golden vectors re-pinned.
468
469### 4.3 Sealed versus plaintext, with the reason for each
470
471| Column | At rest | Why |
472|---|---|---|
473| title, location, notes | sealed | content |
474| calendar name, member display name | sealed | content (Sheets seals titles and tab names) |
475| UID | sealed + SHA-256 | some generators derive it from the title; the hash is the re-import key |
476| member address | **plaintext** | home spec ruling 5; reminder email needs it; the team typed it |
477| `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) |
478| `rrule` | **plaintext** | structure, not prose β€” the same class as a format record; reveals nothing the instants don't |
479| kind, status, sequence, colours, ids, timestamps | plaintext | shape |
480| reminder offsets, `due_at`, `occurrence_key` | plaintext | the queue is what `NextDue` reads |
481| feed token | hashed | a stored plaintext token is a live read capability in the bucket |
482
483### 4.4 The leak list, stated once and never contradicted
484
485Publish the database and you learn:
486
4871. **The team's free/busy in full**, past and future, including recurrence
488 patterns and which zone each event was authored in. This is a bigger
489 leak than Sheets' "shape and size of every sheet", and the README says
490 so in those words.
4912. Who is on the team, by readable address, and who owns which calendar.
4923. Who set reminders how far ahead, and when the next one fires.
4934. That some events are cancelled or tentative, and how many are meetings
494 versus commitments versus notes.
495
496You learn **no title, place or note**, and no name of anyone outside the
497team (there are none yet β€” attendees are deferred).
498
499Three leaks *outside* the database, listed because a rule that only covers
500the bucket invites reading them as covered:
501
5025. **A reminder email carries the title** through the mail relay and into
503 the recipient's mailbox. That is delivery, not storage, and it is the
504 product working β€” but the relay's logs are not ours.
5056. **A subscription feed puts the calendar's plaintext in whatever the
506 subscriber pointed at it** β€” Google, Apple, a phone. The token is in a
507 URL in someone else's account (Β§6.4).
5087. **The platform's timer registry holds reminder instants** (Β§1).
509
510A compromised live host reads everything, as in Sheets. **We never claim
511"we can't read your data."**
512
513---
514
515## 5. Reminders
516
517home/AGENTS.md: *"A dormant instance runs no timers… This shipped twice in
518one spec before it was caught."* So the design starts from the premise that
519the process is not there when the reminder is due.
520
521### 5.1 One queue, one due-time, two ways to be woken
522
523- `reminders` is the **rule** ("10 minutes before, by email, for me").
524- `reminder_queue` holds **exactly one pending row per rule**: the next
525 un-fired occurrence, computed by the expander (first occurrence whose
526 `start βˆ’ offset > now`, honouring `exdates` and overrides β€” an override's
527 *moved* start governs its reminder). When a row fires, the rule's next
528 occurrence is computed and enqueued. The queue is bounded by the number
529 of rules, so a forever-daily standup costs one row, not infinity.
530- `NextDue()` is `SELECT MIN(due_at) FROM reminder_queue WHERE fired_at IS
531 NULL`. The activator polls it before hibernating and wakes us then.
532- **Belt and braces: one named platform timer.** On every queue mutation,
533 `carlos.ScheduleAt(ctx, "reminders", nextDue, "/jobs/reminders")` β€”
534 upsert by name, so it is always the single next instant. One timer per
535 instance, never one per reminder, because the host cap is 1,000 shared
536 across every app on the box and a per-occurrence timer would need the
537 materialised occurrences Β§2.3 refuses. A due time past the 400-day
538 horizon is not registered; `NextDue` still answers it, and the next
539 mutation inside the horizon re-asserts.
540- **The drain runs on boot, on every tick, and opportunistically on any
541 request** when `MIN(due_at) ≀ now` (checked at most once a minute via an
542 in-memory stamp). Three entrances, one function, all idempotent. Boot
543 covers the activator's wake; the tick covers the registry timer; the
544 request path covers a team that is simply using the app while a reminder
545 comes due.
546- **The drain works inside the request and returns 204.** Fifty SMTP sends
547 at 200 ms is ten seconds, inside the platform's timeout. Past a hundred
548 due rows it drains a page, re-asserts the timer for *now*, and returns β€”
549 the platform re-ticks and it continues. 5xx on a mailer failure so the
550 platform retries; the queue rows are the real retry state.
551
552Off platform (`ErrNotOnCarlos`), the request-path drain is the only
553entrance and `run-dev.sh` says so. Tests fake the control socket.
554
555### 5.2 Idempotency β€” at-least-once with a claim, and a deterministic Message-ID
556
557Which is worse for a reminder: fired twice or never fired? Never. A
558reminder that does not fire is the product failing at its one job; a
559duplicate is a nuisance. So:
560
5611. In a transaction, select due rows `WHERE fired_at IS NULL AND (claimed_at
562 IS NULL OR claimed_at < now βˆ’ 5 min)` and set `claimed_at = now,
563 attempts += 1`. The claim is the lease.
5642. Send, **with `Message-ID: <{reminder_id}.{occurrence_key_hash}@{host}>`**
565 β€” deterministic per occurrence. A crash between send and mark yields a
566 second send with the same Message-ID, which every mail client threads
567 or drops as a duplicate. That is the entire duplicate story, and it
568 costs one header.
5693. Set `fired_at`, record `late_by_sec = now βˆ’ due_at`, enqueue the rule's
570 next occurrence, re-assert the timer.
571
572A row that reaches five attempts stops and shows on the person's settings
573page as "could not deliver" with the occurrence β€” visible failure, never
574silent. `carlos.TickOccurrence` is not our dedupe key (the queue rows are),
575but the handler reads it and skips an occurrence it has already drained, so
576a platform redelivery is a no-op rather than a second scan.
577
578**Lateness is visible.** The Today page shows a reminder that fired more
579than ten minutes late as "reminded late (instance was asleep)". The house
580rule is to say the true thing; an instance that hibernates through a
581reminder should say so rather than let the person conclude they missed an
582email.
583
584### 5.3 Reminders are per person
585
586Every calendar gets this wrong because ICS `VALARM` lives on the event, and
587the event is shared. On a team calendar, "remind me" is about *me*. So a
588reminder row has a `member_id`, defaults come from the member's settings
589and are applied to the creator at event creation, and each other member can
590add their own from the event page. Import attaches `VALARM`s to the
591importing member; export emits only the exporting member's.
592
593Who is reminded of what, by default: the creator, of what they create.
594Nobody else, until attendees exist (Β§7). A team calendar where everyone is
595reminded of everything is a team calendar everyone mutes.
596
597### 5.4 Channel
598
599**Email, tonight, plain text, one line of subject.** `rastrillo/mail` is the
600one outbound surface; a production instance with no mailer refuses to boot
601(Docs' fail-closed shape). Subject: "Standup in 10 minutes". Body: the time
602in the recipient's zone, the location, the link. No HTML.
603
604Free with it: the **Today page lists reminders due in the next hour** as a
605callout β€” a scriptless in-app reminder that costs nothing. Web push
606(service worker, VAPID, script-only by nature) is deferred with a trigger
607(Β§7).
608
609---
610
611## 6. ICAL
612
613### 6.1 Import
614
615`POST /import` with a file, **synchronous**, 10 MB cap, parsed in the
616request, nothing persisted but the events. Sheets runs XLSX import as a
617background job with a status page; a 5 MB `.ics` parses in well under a
618second, the rastrillo `jobs` package is in-memory and a deploy ends it, and
619a blob store is a whole surface (Β§2.3 of Sheets) to add for a file we do not
620need to keep. A large-file job is deferred with a trigger (Β§7).
621
622Sharp edges, each with a decision:
623
624- **`TZID` β†’ IANA.** Three tiers: an IANA name verbatim; a Windows name
625 (`W. Europe Standard Time`) via an embedded CLDR `windowsZones` table
626 (~140 rows); a Mozilla/Outlook path (`/mozilla.org/…/Europe/Dublin`) by
627 extracting the trailing IANA segment. Anything else: **imported as
628 floating with a per-file notice** β€” "N events had a time zone Calendar
629 did not recognise and were imported as floating; open one to pin it".
630 The `VTIMEZONE` blocks themselves are not used to *resolve* times; our
631 tzdata is the authority, and a file's `VTIMEZONE` is routinely years
632 stale.
633- **UID.** Preserved, sealed, hashed. **Re-importing the same UID updates
634 the event if the incoming `SEQUENCE` is higher, else skips** β€” so
635 importing an exported file twice does not duplicate. Our own UIDs are
636 `<ulid>@<instance-host>` and are never regenerated.
637- **`RECURRENCE-ID` β†’ override rows. `EXDATE` β†’ `exdates`. `RDATE` β†’ an
638 override row whose `recurrence_id` the rule does not generate**, which
639 the recurrence set renders as an addition (Β§2.3 step 4). Export of that
640 case is imperfect (an `RDATE` plus a `RECURRENCE-ID` for it); recorded
641 in the deferred table rather than over-designed.
642- **All-day `DTEND` is exclusive** and the biggest single source of
643 off-by-one in the wild. One conversion function, one test, and a fixture
644 from each of five real exporters.
645- **`VALARM`** β†’ reminders for the importing member (Β§5.3). `ATTENDEE` β†’
646 counted and dropped tonight, with the count in the notice.
647- **`DTSTAMP`, `LAST-MODIFIED`, `SEQUENCE`** β†’ `updated_at`, `sequence`.
648 `CREATED` β†’ `created_at` if present.
649- **Line folding, `\,` `\;` `\n` escapes, `X-` properties** β€” folded lines
650 are the first parser bug everyone ships. Unknown `X-` properties are
651 dropped, not stored; storing them means round-tripping them, and that is
652 a corpus commitment we are not making tonight.
653
654### 6.2 Export
655
656`GET /calendars/{id}.ics` and `GET /events/{id}.ics`, behind the session.
657`PRODID:-//Oficina//Calendar//EN`, `VERSION:2.0`, `X-WR-CALNAME` from the
658sealed name, `X-WR-TIMEZONE` from the team zone.
659
660**Tonight: IANA `TZID`s and no `VTIMEZONE` components.** RFC 5545 Β§3.6 says
661`VTIMEZONE` MUST be present for every `TZID`; Google, Apple, Fastmail and
662Thunderbird all resolve IANA `TZID`s without one; Outlook desktop does not
663reliably. Generating `VTIMEZONE` from Go's `time.Location` means probing
664transitions (Go does not export them) β€” ~120 lines and an evening of edge
665cases (Lord Howe's 30-minute DST, Kolkata's none). It is the kind of thing
666that eats an overnight, so it is deferred with a concrete trigger (Β§7) and
667the export is honestly labelled non-conformant in the README until then.
668
669`UNTIL` always in `Z`. Overrides export with `RECURRENCE-ID;TZID=…`. Only the
670exporting member's `VALARM`s, and none at all on a feed unless `?alarms=1`.
671
672### 6.3 The round-trip corpus is a gate
673
674Import β†’ export β†’ import must be stable on our own files, and our export
675must import cleanly into Google, Apple and Thunderbird (checked by hand on
676the canary the first morning, then by fixture). Foreign fixtures: one each
677from Google Calendar, Apple Calendar, Outlook, Thunderbird and Fastmail β€”
678real exports, not hand-written.
679
680### 6.4 The subscription feed, and what it means for the one rule
681
682**Yes: `GET /feed/{token}.ics`**, a capability URL outside the session
683group, exactly Sheets' `/l/{token}`. Token hashed at rest. Per person and
684per calendar, created and revoked from the sharing page. `ETag` and
685`Last-Modified` from the calendar's max `updated_at`, `304` on match.
686
687What it means, said plainly:
688
689- **It does not break the rule.** The live instance decrypts for a
690 capability holder; the bucket still holds ciphertext; the token at rest
691 is a hash and not a capability.
692- **It moves the plaintext somewhere else.** The subscriber's Google or
693 Apple account now holds a full plaintext copy and refreshes it. The
694 sharing UI says this in words before the URL is shown β€” the same shape as
695 Sheets Β§9.5's "a guest grant discloses the team name".
696- **It is a hibernation anchor, and the cost is ours.** Apple polls
697 subscribed calendars as often as every 15 minutes; Thunderbird every 30;
698 Google roughly daily. Each poll wakes the instance. Three Apple
699 subscribers means the instance never sleeps. The `304` is cheap but the
700 wake is not free. `last_polled_at` and `poll_count` are shown beside
701 each feed on the sharing page β€” "polled 61 times this week" is
702 information a person can act on.
703
704Importing *from* an external feed (holidays, a sports fixture list) needs a
705declared schedule (`carlos schedule set -every 6h -path /jobs/feeds`) and a
706poller; deferred with a trigger (Β§7).
707
708---
709
710## 7. Scope β€” the maximally viable cut
711
712What one overnight session can finish to a deployed, tested standard,
713starting from Sheets' skeleton to copy: roughly 3,500 lines of Go and
714templates. The items below are ordered so that stopping after any line
715leaves a coherent product. Gates come with the item they guard.
716
717### Ships tonight
718
7191. **Skeleton.** `cmd/calendar`, chi, GORM over `modernc.org/sqlite`,
720 additive migrations, `seal` lifted from Sheets with the new label,
721 magic-link sign-in with `$CALENDAR_ALLOWED_ADDRESSES` (Docs' shape),
722 `/mode` cookie with three states, `rst-*` attribute grammar on
723 v0.23.0, `estilo` theme, CSRF app-wide, catalog keys for twelve
724 locales (month and weekday names and the date pattern are keys β€”
725 there is no CLDR dependency), 300-line caps on Go and JS. **Gates from
726 here:** plaintext grep, no-content-in-logs, two-member 404-not-403
727 isolation, scriptless-affordance diff, line caps.
7282. **Calendars and grants.** One team calendar created with the instance;
729 personal calendars per member, private by default; share to a member as
730 viewer or editor. Colour as OKLCH intent through Sheets' shim.
7313. **Events.** Create, edit, delete. Timed, all-day; zone from the member
732 preference; title, location, notes sealed. Ruling 1 storage. **Gate:**
733 the re-derive test (Β§3.1).
7344. **Views, all scriptless.** Today (default), Day, Week with server-packed
735 overlaps (Β§8.1), Month as a `<table>` with per-day chips, Agenda (next
736 fourteen days). Prev/next/today as links. Every empty slot is a link to
737 a prefilled `/events/new`. 320 px: month reflows to a stacked day list
738 in CSS; week claims the two-dimensional exemption and scrolls; day and
739 agenda reflow natively.
7405. **Recurrence subset, exceptions, split.** Expander with the rrule-go
741 oracle. `scope=this|following|all` on edit and delete. **Gate:** golden
742 vectors, and "editing this occurrence to 10:00 moves its reminder to
743 09:50".
7446. **Reminders.** Rules, queue, `NextDue`, the one named timer, three
745 drain entrances, claim-lease, deterministic Message-ID, plain-text email,
746 the Today callout. **Gate:** kill-between-every-line test with a faked
747 control socket; the canary verifies a real wake the first morning.
7487. **ICS.** Import (synchronous), export per calendar and per event,
749 subscription feed with hashed token and `304`. **Gate:** round-trip
750 corpus on our own files plus the five foreign fixtures.
7518. **Two JS enhancements, both under 300 lines.** rastrillo's `datetime.js`
752 on every date field (free), and `create.js`: pointer-drag across empty
753 slots in day and week navigates to `/events/new?start=…&end=…`. Nothing
754 else. **There is no JSON route tonight, because no enhancement needs
755 one.**
7569. **Deploy** with `carlos deploy`, verify `GET /api/version` on the
757 process, not the edge.
758
759Honest risk ordering: ICS parsing edge cases and week-view packing are the
760two items most likely to overrun. If the night is short, item 7 ships
761import and export and defers the feed to the first morning β€” a calendar
762without a feed is still a calendar; a calendar with a broken parser is a
763calendar that eats files.
764
765### Deferred, each with its trigger
766
767| Not built | Trigger |
768|---|---|
769| **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. |
770| **Guests** (per-calendar grants to non-members) | Same trigger; same dependency. |
771| **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. |
772| **External feed subscription** (holidays, fixtures) | A member asks for public holidays. Needs a declared schedule and a poller; `carlos schedule set -every 6h`. |
773| **`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. |
774| **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. |
775| **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. |
776| **Skip-ahead expansion** | A month render exceeds 50 ms on a canary profile with a real team's data. |
777| **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. |
778| **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. |
779| **Search** (titles, via decrypt-and-scan at dogfood N) | Asked for once. Full-text has Docs' FTS5 problem and no design. |
780| **Background import job for large files** | An import exceeds the request timeout. |
781| **`RDATE` round-trip exactness** | The corpus shows an `RDATE` file coming back different. |
782| **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. |
783| **Anything with "AI" in it** | β€” |
784
785---
786
787## 8. Routes
788
789Sheets' style: session-guarded groups, `/api` only for JSON, capabilities
790outside the session. Every row is scriptless unless the last column says
791otherwise.
792
793| Method | Path | Scriptless | Notes |
794|---|---|---|---|
795| GET | `/` | yes | β†’ `/today` when signed in; sign-in page otherwise |
796| GET | `/signin` | yes | |
797| POST | `/signin` | yes | allowlist-gated before the mail is sent; same 303 either way |
798| GET | `/auth/verify` | yes | magic-link redemption |
799| POST | `/signout` | yes | |
800| POST | `/mode` | yes | light/dark/auto cookie, returns to `return=` |
801| POST | `/_locale` | yes | mounted by the framework |
802| GET | `/static/*` | β€” | fingerprinted assets |
803| GET | `/feed/{token}.ics` | yes | **capability; outside the session.** `ETag`/`304`. `?alarms=1` |
804| POST | `/jobs/reminders` | β€” | **platform tick; `carlos.Tick` guarded.** Drains the queue inside the request; 204 / 5xx |
805| GET | `/api/next-due` | β€” | mounted by the framework from `Options.NextDue` |
806| GET | `/today` | yes | the front door β€” Β§8.1 |
807| GET | `/day/{yyyy-mm-dd}` | yes | one packed column + the day's list |
808| GET | `/week/{yyyy-mm-dd}` | yes | seven packed columns; the date is any day in the week |
809| GET | `/month/{yyyy-mm}` | yes | `<table>`; `+N more` links to the day |
810| GET | `/agenda` | yes | next fourteen days as a list; `?from=` |
811| GET | `/calendars` | yes | list, colours, archive |
812| POST | `/calendars` | yes | create |
813| GET | `/calendars/{id}` | yes | settings: name, colour, zone note |
814| POST | `/calendars/{id}` | yes | rename, recolour |
815| POST | `/calendars/{id}/archive` | yes | |
816| GET | `/calendars/{id}/share` | yes | grants and feeds, with the poll counts and the plaintext-elsewhere notice |
817| POST | `/calendars/{id}/share` | yes | grant a member |
818| POST | `/calendars/{id}/share/revoke` | yes | |
819| POST | `/calendars/{id}/feeds` | yes | mint a feed; shows the URL once |
820| POST | `/calendars/{id}/feeds/revoke` | yes | |
821| GET | `/calendars/{id}.ics` | yes | full export for the session member |
822| GET | `/events/new` | yes | `?start=&end=&date=&calendar=` prefill; `create.js` navigates here |
823| POST | `/events` | yes | create; kind radio; recurrence picker |
824| GET | `/events/{id}` | yes | single, or a master's page (shows the rule and the exceptions) |
825| GET | `/events/{id}/{yyyymmddThhmm}` | yes | **one occurrence** of a series |
826| POST | `/events/{id}` | yes | edit; `scope=this\|following\|all` on a series or occurrence |
827| POST | `/events/{id}/delete` | yes | same `scope` radio; "just this one" is the default |
828| POST | `/events/{id}/reminders` | yes | add my reminder |
829| POST | `/events/{id}/reminders/remove` | yes | |
830| GET | `/events/{id}.ics` | yes | one VEVENT plus its overrides |
831| GET | `/import` | yes | upload form |
832| POST | `/import` | yes | synchronous; redirects to a notice with counts |
833| GET | `/settings` | yes | zone, week start, default reminders, undeliverable reminders |
834| POST | `/settings` | yes | |
835| GET | `/healthz` | β€” | |
836
837**Enhanced (script adds, floor remains):** `/events/new` and the edit form
838get `datetime.js`; `/day` and `/week` get `create.js`. That is the entire
839enhanced surface tonight. The scriptless-affordance diff gate fetches every
840page script-stripped and asserts no affordance is missing.
841
842---
843
844## 9. What makes it feel considered
845
846### 9.1 Today is the front door, and it says how full the day is in a sentence
847
848The question a person opens a calendar to answer is "what's next", not
849"what does September look like". So `/` is Today: this day's list, then the
850rest of the week as a list, with the month one link away.
851
852At the top, one sentence, computed server-side, in the person's locale:
853
854> *Wednesday. Four things; the first at 09:30, the last ends at 17:00.
855> Two hours and fifteen minutes unbooked between them.*
856
857And when there is nothing left to give:
858
859> *There is no unbooked hour today.*
860
861That sentence is the product's whole opinion about overfull days. It
862states, never nags, never colours the day red. Colour never carries
863meaning alone, and this carries it in words, which is stronger anyway.
864Booked time counts meetings and commitments; notes do not count (Β§9.2).
865
866### 9.2 A meeting, a commitment and a note are three different things
867
868Most calendar bloat comes from one event type pretending to be all three.
869`kind` is one radio group at creation:
870
871- **Meeting** β€” has, or will have, other people. Gets the default
872 reminder. Counts as booked.
873- **Commitment** β€” a promise to yourself: a focus block, the gym, "write
874 the spec". No reminder by default (you know), counts as booked, drawn
875 with an outline rather than a fill so it reads differently at a glance
876 *and* in greyscale.
877- **Note** β€” a fact about the day, not a claim on it: "Anna on leave",
878 "release day". All-day by nature, never counts as busy, never reminded,
879 renders as a line above the grid.
880
881Exports map to `X-OFICINA-KIND` and `TRANSP:TRANSPARENT` for notes, so the
882distinction survives a round trip and other calendars at least see notes
883as free time.
884
885### 9.3 The past is written in the past tense
886
887Agenda and Today render a future item as "09:00–09:30 Standup" and a past
888one as "Standup, ended 09:30". Past items drop to muted weight. No strike,
889no colour change, and the tense carries it for a screen reader too. It is
890free and it is the difference between a list of times and a day.
891
892### 9.4 No spanning bars in month view
893
894A multi-day event repeats as one chip per day with `β†’`/`←` continuation
895markers. Spanning bars are the largest single source of layout complexity
896in every calendar ever built, they break at week boundaries anyway, and the
897information they carry β€” "this continues" β€” is a glyph. A month is for
898scanning density, not reading; the day and week views are for reading.
899This ships scriptless and stays that way even with JS.
900
901### 9.5 One question on deleting a recurring event, and "all" is not the default
902
903*Just this one Β· This and the ones after it Β· All of them.* "Just this one"
904is selected. "All of them" is last and is not a red button; it is the same
905button with the count beside it: "All 52 of them". The number is the
906warning.
907
908### 9.6 Your zone is stated; the other zone is a whisper
909
910Every time on every page is the viewer's. When an event was authored
911elsewhere, "09:00 Dublin" sits beside it in secondary text, and only then.
912No `(GMT+1)` anywhere, ever.
913
914---
915
916## 10. The things everyone gets wrong, and what we do from commit one
917
9181. **Storing UTC as truth.** Future zoned events drift when a government
919 changes the rules. Ruling 1: wall-clock plus zone is the record; the
920 instant is an index (Β§3.1).
9212. **All-day events as midnight-to-midnight timed events.** They land on
922 the wrong day for anyone in another zone β€” the "birthday on the wrong
923 date" bug. All-day is a third kind with no instant (Β§3.2).
9243. **Exclusive `DTEND` on all-day events**, shown inclusive in the UI. One
925 conversion function, one test, five real fixtures (Β§6.1).
9264. **Sniffing the browser's zone and silently rewriting the view.** Stated
927 preference; a notice, never a rewrite (Β§3.3).
9285. **Materialising occurrences.** Then "edit all" is a migration and
929 "forever" is a table that grows. Occurrences are computed per window,
930 overrides are rows, exdates are a column (Β§2.3).
9316. **Querying overrides by their original time.** An occurrence dragged
932 into the visible week from outside it vanishes. Query by the new time
933 (Β§2.3 step 4).
9347. **Orphaning overrides** when the master is deleted or its rule shortened.
935 Same transaction, always (Β§2.2).
9368. **`COUNT` after `EXDATE`.** COUNT counts generated instances before
937 exclusion; a test pins it.
9389. **`UNTIL` local versus UTC.** Lenient in, strict out (Β§3.5).
93910. **The 31st, the fifth Friday, and February 29.** Skip, per RFC Β§3.3.10;
940 never clamp, never crash. The copy says "in years that have one" for
941 the leap-day case, because Google does the other thing and someone
942 will ask.
94311. **A reminder system that assumes a live process.** Queue plus
944 `NextDue` plus one platform timer plus three drain entrances, work
945 inside the request (Β§5.1).
94612. **Duplicate reminders after a crash, with no idempotency key.** Claim
947 lease plus deterministic `Message-ID` (Β§5.2). And its mirror: a
948 reminder that never fires and nobody knows β€” `late_by_sec`, the
949 "could not deliver" list, and the "reminded late" notice.
95013. **Reminders as a property of the event on a shared calendar.** Per
951 person (Β§5.3).
95214. **`+N more` that opens nothing without script.** It is a link to the
953 day (Β§8).
95415. **Week-view geometry as the only carrier of duration.** Every chip
955 prints its times as text; the absolute positioning is decoration on an
956 ordered list (Β§8.1).
95716. **"Today" computed in the server's zone.** The viewer's, always (Β§3.4).
95817. **Calendar colour as the only encoding of which calendar an event is
959 on.** Every chip carries the calendar name in its accessible name, and
960 commitments are outlined rather than filled (Β§9.2).
96118. **A subscription feed treated as free.** It is a hibernation anchor
962 and a plaintext copy elsewhere; both are said in the sharing UI (Β§6.4).
96319. **Content in error strings.** An import error says "line 412: DTEND
964 before DTSTART", never the summary on line 410. Test-enforced.
96520. **Fifteen-minute time steps.** They hide the 09:10 meeting. Step is
966 five; `datetime.js` accepts anything.
967
968---
969
970## 11. The scriptless layout, concretely
971
972Called out separately because it is the constraint the user most wants
973answered, and because it is easier than it looks once the server does the
974geometry.
975
976**Month** β€” a `<table>`: `<thead>` of weekday names (catalog keys, in the
977member's week-start order), one `<tr>` per week, one `<td>` per day with a
978date heading that links to `/day/…`, a `+` link to `/events/new?date=…`, an
979`<ol>` of at most four chips (each an `<a>` to the event or occurrence) and
980a `+N more` link to the day. Today's cell carries `aria-current="date"` and
981a text marker, not only a tint. Days outside the month are muted and still
982real. At 320 px, CSS turns rows and cells into blocks with the date as a
983heading and empty days collapsed β€” the month becomes a list, in CSS only.
984
985**Week and day** β€” an all-day strip (a plain list), then seven `<div>`
986columns each `position: relative` at 24 Γ— hour-height, each holding an
987`<ol>` of chips positioned by inline `style="top:…%;height:…%;left:…%;
988width:…%"` (rastrillo's default CSP allows inline style attributes β€” its
989own partials use them). **The server runs the overlap packer:** cluster
990events that transitively overlap, assign each a lane by earliest start,
991width is `100%/lanes` and left is `lane Γ— width`. Eighty lines of Go, unit
992tested on the classic three-way overlap. DOM order is by day then start,
993which is the reading order a screen reader wants; every chip prints its
994times in text. Beneath the chips, 48 half-hour slot links per column with
995`rst-sr-only` labels ("New event, Wednesday 09:30") and 24 px hit areas β€”
996that is click-to-create with no script, and `create.js` turns a drag across
997them into one navigation with a start and an end.
998
999**Agenda** β€” a list grouped by day heading. The best mobile view and the
1000best scriptless view, and the shape Today is built from.
1001
1002**Forms** β€” rastrillo's `field-datetime` (native `datetime-local`,
1003step 300) and `field-daterange`; the recurrence picker is a `<select>` for
1004the frequency, a checkbox row for weekdays, a radio for "on day N / on the
1005Nth weekday", a number for the interval, and a radio for the end β€” all
1006plain controls. The all-day checkbox swaps `datetime` fields for `date`
1007fields on the next render, and `datetime.js` does it live.
1008
1009---
1010
1011## 12. Rulings this brainstorm asks Paul to make
1012
1013Proposed for the spec's Β§1 table, each with its rejected alternative:
1014
1015| # | Proposed ruling | Rejected |
1016|---|---|---|
1017| 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 |
1018| 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) |
1019| 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 |
1020| 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` |
1021| 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 |
1022| 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 |
1023| 7 | **Reminders are per person** (Β§5.3) | Per event |
1024| 8 | **Email is the only channel tonight; the Today callout is the scriptless in-app reminder** (Β§5.4) | Web push first |
1025| 9 | **The viewer's zone is a stated preference, never sniffed** (Β§3.3) | Browser detection with silent update |
1026| 10 | **Floating events are import/export only** (Β§3.2) | Offering them in the UI |
1027| 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 |
1028| 12 | **Export emits IANA `TZID`s without `VTIMEZONE` tonight, labelled non-conformant** (Β§6.2) | Generating `VTIMEZONE` tonight; refusing to export |
1029| 13 | **Three kinds: meeting, commitment, note** (Β§9.2) | One event type with flags |
1030| 14 | **Today is the front door, with the day's shape in one sentence** (Β§9.1) | Month grid as landing |
1031| 15 | **No spanning bars in month view, with or without script** (Β§9.4) | Bars as the JS enhancement |
1032| 16 | **Sign in locally by magic link and allowlist tonight; adopt the home's assertion when the helper exists** (Β§0) | Blocking on the helper |
1033| 17 | **Import is synchronous, 10 MB, nothing persisted but events** (Β§6.1) | A background job and a sealed blob store |
1034
1035---
1036
1037## 13. Open
1038
1039- **How the activator wakes us from `NextDue`.** `serve.go` says it
1040 "hibernates knowing when to wake it"; whether the wake is a bare process
1041 start or a POST somewhere is in the platform spec
1042 (`2026-08-23-scheduled-work-design.md Β§7`), which this session could not
1043 read. The three-entrance drain is designed so that either answer works;
1044 the first canary morning confirms which it is.
1045- **Whether `carlos.ScheduleAt` is reachable from a hibernating exec child
1046 at boot** β€” `schedule.go` says the socket is bound before spawn, so it
1047 should be. Verified on the canary, not assumed.
1048- **Locale month and weekday names versus `Intl`.** The scriptless render
1049 uses catalog keys; `datetime.js` uses `Intl`. A browser-drive gate that
1050 compares them per locale would catch a divergence; whether it is worth an
1051 overnight hour is Paul's call.
1052- **The suite's own domain** β€” inherited from Sheets Β§15; the feed URL is
1053 the string most expensive to change later after the RP host.
1054