oficina / calendar Public

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

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Calendar
2
3A calendar for one team, on one instance. Alongside `oficina/docs` and
4`oficina/sheets`, on the same platform, in the same shape.
5
6This file is binding. Where it disagrees with a general skill, this file
7wins. Where it disagrees with the design spec, the spec wins and this
8file is wrong β€” fix it in the same commit.
9
10**The design:** `docs/superpowers/specs/2026-09-02-calendar-design.md`.
11The raw material it came from, including the rejected alternatives, is
12`docs/superpowers/brainstorms/2026-09-02-calendar-brainstorm.md`.
13
14---
15
16## The one rule
17
18> **An event's contents are sealed at rest. The bucket never holds a
19> plaintext title, place or note.**
20
21The falsifiable form: **publish this database in full and the worst
22anyone learns is the shape of the team's time** β€” when they are busy,
23for how long, how often, and in which zones β€” **and who is on the team.
24Never what any event is, where it is, or what was written about it.**
25
26`TestTheDatabaseNeverHoldsAPlaintextTitle` enforces it from the first
27commit, and it greps the raw SQLite bytes and the WAL rather than
28asking SQLite: a column that was never sealed looks identical through
29Go and completely different on disk. It checkpoints first, or it would
30pass by reading a file the data has not reached.
31
32### What this rule does NOT cover, said here so it is never read as covering it
33
341. **Times are plaintext, and free/busy is a real leak.** Every view is
35 a range query, `NextDue` reads `MIN(due_at)` before any request, and
36 the platform's own timer registry holds reminder instants anyway.
37 Sealing the instants would claim a property the estate does not
38 have. A published database gives up the team's whole free/busy.
392. **A reminder email carries the title** through the relay and into a
40 mailbox. That is delivery, not storage, and it is the product
41 working β€” but the relay's logs are not ours.
423. **A subscription feed puts plaintext in whoever's account it was
43 pasted into.** The share page says so before the button, not after.
444. **A compromised live host reads everything.** We never claim "we
45 cannot read your data."
465. **A PUBLISHED booking page is a live free/busy oracle for that host,
47 to anyone, without the database.** Every calendar the host can see
48 shapes the answer. This is a NEW disclosure, not the one above, and
49 the Publish button says so in a sentence before it is pressed.
50
51---
52
53## Settled decisions
54
55| # | Ruling | Rejected | Who, when |
56|---|---|---|---|
57| 1 | **Store what the person said**: wall-clock time plus IANA zone is the record; the UTC columns are a derived index | Storing the instant as truth β€” a tzdata update then moves a meeting and nobody can say what was meant | This session, 2026-09-02 |
58| 2 | *(Superseded 2026-09-29: the suite's theme is estilo's Calma β€” estilo AGENTS.md row 8 β€” so Calendar now serves `theme.ForApp("calendar")`, written to static/theme.css by scripts/writeestilo and pinned by TestThemeIsEstilosForCalendar.)* Pin rastrillo v0.23.0 and take theme `day` directly from `ui.ThemeCSS` | Consuming `estilo`, which is `day` byte-for-byte today and pins v0.22.0 β€” adopting it buys nothing and drags us back a release ("the two-pins trap", estilo's own AGENTS.md) | This session, 2026-09-02 |
59| 3 | This app's own components take `cal-` | `rst-` is the framework's and a future release may claim a name; `estilo-` is the suite's, and a month grid is not a suite component until a second app copies one | This session, 2026-09-02 |
60| 4 | The recurrence subset is the six a picker offers; anything else is stored verbatim, flagged, and not expanded | Refusing a rule we cannot expand β€” an importer that eats a rule it does not understand eats data | This session, 2026-09-02 |
61| 5 | Reminders are per PERSON, not per event | Per-event reminders β€” two people on one meeting want different warnings, and the fix needs a person column anyway | This session, 2026-09-02 |
62| 6 | Delivery is at-least-once; every ambiguous case resolves towards sending | At-most-once β€” a reminder that never arrives is worse than one that arrives twice | This session, 2026-09-02 |
63| 7 | Local sign-in is a magic link, with no passkey second factor | Passkeys β€” `passkey.Config` derives its relying party from this instance's host, so one person in three team instances enrols three credentials. Same ruling docs made | This session, 2026-09-02 |
64| 7a | Local sign-in uses Keymail for an address whose domain runs Keymail, and the magic link for every other address β€” chosen by the domain alone, never by the allowlist (an oracle otherwise). The Keymail hop is a continuation page, never a 303 out of the form POST (Safari applies form-action to it) | Forcing every address to a magic link | Paul, 2026-10-06 ("I do want it to use keymail") |
65| 8 | A new event defaults to the person's PERSONAL calendar | Defaulting to the team calendar β€” the cost of forgetting to change a dropdown should fall on the side of privacy | This session, 2026-09-02 |
66| H1 | **Optional Home sign-in connects to an existing person after fresh local proof and explicit confirmation; local sign-in remains available** | Treating the Home subject as an email or allocating a new person | User-approved suite connection, 2026-09-10 |
67| B1 | A booking is confirmed by a BUTTON on the page the mailed link opens β€” a same-origin POST β€” never on submit and never on GET | Instant confirmation; confirming on the GET, which every link-scanning mail gateway would press | Booking spec Β§1, 2026-09-07 |
68| B2 | Everything the host can see is busy β€” every calendar they hold a grant on | Personal only | Booking spec Β§1, 2026-09-07 |
69| B4 | The public identifier is a chosen plaintext `handle` plus a GENERATED page code | A chosen slug β€” prose joined to events | Booking spec Β§1, 2026-09-07 |
70| B5 | Booking mail is the ONE outbound to a non-team address, and only to an address the recipient typed in order to receive it | Emailing quick-add attendees "while we're at it" | Booking spec Β§1, 2026-09-07 |
71| B8 | A held slot is a tentative EVENT | A holds table the slot query consults | Booking spec Β§1, 2026-09-07 |
72| B11 | The EVENT's `sequence` is the one revision counter, bumped as `sequence + 1`; every booking-side write names the revision it expects | A booking sequence; a stale-read `+1` | Booking spec Β§1, 2026-09-07 |
73| B12 | Hold and reschedule serialise on a process mutex AND take SQLite's write lock before the final busy read, inside the transaction that inserts | The mutex alone; a busy read before `BEGIN` | Booking spec Β§1, 2026-09-07 |
74| B13 | Mail is a queue table, one row per (booking, kind, recipient, revision), claimed with an identity and a lease, acknowledged conditionally on that identity | A marker column | Booking spec Β§1, 2026-09-07 |
75| B14 | The management token is 32 random bytes, hex, stored SEALED as well as hashed | Hash only, as feeds do | Booking spec Β§1, 2026-09-07 |
76| B15 | A hold expires at the EARLIER of thirty minutes and the meeting's start; confirmation requires now < start | Thirty minutes flat | Booking spec Β§1, 2026-09-07 |
77| B16 | No sender error is "definite"; nothing is freed because a send failed | Marking holds failed | Booking spec Β§1, 2026-09-07 |
78| B17 | A slot is the same length on the wall clock and the elapsed clock, or it is not offered | Round-tripping the start only | Booking spec Β§1, 2026-09-07 |
79| B18 | A booked event's scheduling β€” start, end, tz, all_day, kind, rrule β€” belongs to the booking flow; the host's form shows them disabled and a save that changes any is refused | Invariant checks on host-supplied times | Booking spec Β§1, 2026-09-07 |
80| B19 | Mail says the CURRENT state; a transition deletes every unsent row the new state makes false | Historical snapshots per row | Booking spec Β§1, 2026-09-07 |
81| B20 | The booker sees the page's title and description AS THEY WERE at hold time, snapshotted sealed onto the booking | Live page text | Booking spec Β§1, 2026-09-07 |
82| B21 | Reschedule writes `tz` = the host's current zone atomically with both local strings and both UTC columns | Leaving `tz` as it was | Booking spec Β§1, 2026-09-07 |
83| B22 | Both queues carry a random `claim_id`; every completion mutation is conditional on it and built from the row the claim returned; reminders retry through `retry_at`, never `due_at`; `ErrMailBusy` defers without counting an attempt | Claim by timestamp; retry written into `due_at` | Booking spec Β§1, 2026-09-07 |
84| B23 | Availability FAILS CLOSED: a busy series that could not be expanded to completion, or whose rule this app cannot parse, is a 503, never "free" | Trusting `Expand`'s slice | Booking spec Β§1, 2026-09-07 |
85| B24 | Reschedule repairs the event's reminder queue rows INSIDE its transaction | After commit | Booking spec Β§1, 2026-09-07 |
86| B25 | A booked event's form carries its scheduling snapshot as hidden inputs, including the event's own `tz`, with the controls disabled | Disabled controls alone, which submit nothing | Booking spec Β§1, 2026-09-07 |
87| B26 | Reschedule enumerates the event's reminder RULES and upserts a queue row for each | Rebuilding the rows that exist | Booking spec Β§1, 2026-09-07 |
88
89Don't relitigate one in a drive-by.
90
91---
92
93## Mechanical rules
94
95Rules here are enforced by tests, not by memory. If you find yourself
96relying on remembering one, make it a test instead.
97
98**Time**
99- **The record is `start_local` + `tz`. The UTC columns are an index.**
100 Anything that writes a UTC column without deriving it from the local
101 pair is a bug.
102- **DST has no correct answer and Go does not promise one.** `clock.go`
103 documents what actually happens and `clock_test.go` pins it to real
104 unix numbers. The gap and the fold resolve DIFFERENTLY β€” the gap takes
105 the pre-transition offset, the fold takes the second occurrence. Do
106 not reason about one from the other.
107- **A day is 23, 24 or 25 hours.** Use `DayBounds`, never `Add(24h)`.
108- **`Zone()` takes bounded input on public routes:** `CommonZones` plus
109 the host's own zone. Its cache holds successes only.
110- **A booking slot is the same length on the wall clock and the elapsed
111 clock, or it is not offered.** Both ends round-trip through `ToUTC`.
112- **Every range is half-open.** An inclusive end is how a one-day event
113 shows on two days and how a week query double-counts its boundary.
114- **All-day events are DATES.** Never a datetime at UTC midnight, which
115 puts a birthday on the wrong day west of Greenwich.
116
117**Recurrence**
118- **COUNT counts GENERATED occurrences; EXDATE removes afterwards.**
119- **`Expand` may stop early.** Anything that decides FREE time reads
120 `EventsInRangeStrict` and fails closed on `ErrIncompleteExpansion`.
121- **A day span is a difference of CIVIL dates, never elapsed hours over
122 24.** And advancement carries the series' ORIGINAL clock onto each
123 new date β€” never the previous occurrence's resolved time.
124- **A named date a month does not have is NO occurrence** β€” never
125 clamped. The 31st, 29 February, the fifth Friday.
126- **Overrides are keyed by the occurrence's ORIGINAL local time and
127 rendered at their new one.** The range query is two passes because of
128 it. Skip the second and a moved occurrence vanishes; skip the dedupe
129 and it appears twice.
130- Expansion iterates in **wall clock**. Stepping a UTC instant moves a
131 09:00 standup by an hour for half the year.
132
133**Data**
134- Every scoped read goes through a grant join. **A row that isn't yours
135 404s, never 403s** β€” including for a write, so status codes cannot
136 enumerate the team's calendars.
137- **Booker name, address, note and TOKEN are sealed; the address has
138 the keyed fingerprint. A page's public code is generated, never a
139 chosen slug β€” a slug is prose. Booking windows are plaintext:
140 shape-of-time.**
141- **Every booking transition is a conditional UPDATE naming the state
142 and revision it expects, checking RowsAffected.** Zero rows is
143 "someone moved it first", never a retry.
144- **A booked event's start, end, tz, all_day, kind and rrule belong to
145 the booking flow.** A host edit that changes any of them is refused;
146 the form shows them disabled with the stored values hidden beneath.
147- **Never bind a request body onto a model.** Updates take an explicit
148 column allowlist; the columns absent from it (`calendar_id`,
149 `created_by`, `series_id`) are exactly the ones a hidden field must
150 not move.
151- **Never `sessions.UserID`** β€” under the magic-link plugin the Subject
152 is an address, so it returns `(0, false)` and every query silently
153 scopes to person 0: a calendar that looks empty rather than broken.
154 Use `auth.From(r)`, and `requirePerson` for the id.
155- **A Home session subject is opaque.** Resolve its explicitly linked person ID
156 before any grant lookup. Never pass it to `EnsurePersonSetup` or use it as an
157 address. Recheck current admission and retain AUTOINCREMENT person IDs without
158 reuse or reassignment. Home proof storage uses a separate stable connection key.
159- Migrations are additive-only. Never edit one that has shipped.
160
161**Ticks**
162- **A `time.Ticker` is not a scheduler.** The instance hibernates.
163- **`NextDue` is the earliest CLAIMABLE instant** across reminders, hold
164 expiries, owed booking mail and IP-forgets. New scheduled work goes
165 here, never in a goroutine. Cheap work runs before delivery;
166 reminders get the first half of the delivery budget.
167- **Every queue row has a random `claim_id`;** every completion
168 mutation is conditional on it and checks RowsAffected, and a payload
169 is built from the row the claim returned, never from the candidate
170 read. A reminder's `due_at` is the occurrence minus the offset and is
171 never written by a retry β€” `retry_at` is. Booking mail's `due_at` is
172 retry eligibility only.
173- **No sender error is definite.** Never free a resource because a send
174 "failed". Every send goes through `boundedSender`; `ErrMailBusy` is a
175 deferral, not an attempt.
176- **A reschedule repairs the event's queue rows in the same transaction
177 as the move.**
178- **The tick route is mounted OUTSIDE `csrf.Protect`.** `csrf.Protect`
179 refuses any POST with no `Sec-Fetch-Site`, `Origin` or `Referer`, and
180 the scheduler sends none; inside the protected router a tick 403s
181 forever and the platform reads 4xx as "do not retry". It fails
182 silently and permanently. `TestTheTickRouteIsNotBehindCSRF` stands in
183 front of it and asserts WHICH refusal answered.
184- **Work inside the request.** 202-plus-a-goroutine is a reliable way to
185 be hibernated mid-send.
186- **Ticks are expensive.** `CARLOS_HIBERNATE_IDLE` defaults to 15
187 minutes, so each tick buys a quarter hour of billed residency. The
188 declared schedule is 6h and the precision comes from `NextDue`.
189
190**Frontend**
191- Write the **`rst-*` attribute grammar** (`rst-btn="primary"`), never
192 the class spelling. Adopt per screen, never per element.
193- This app's own components take **`cal-`**.
194- **No affordance may require script.** The scriptless page is the
195 product; `datetime.js` and any later enhancement sit on top of a
196 working floor.
197- **Colour never carries meaning alone.** Every chip prints its own
198 time and title; cancelled says "Cancelled"; past is written in the
199 past tense; today has three signals.
200- Light/dark is three states and **auto is the ABSENCE of
201 `data-theme`**. The attribute takes the light/dark SCHEME, never
202 rastrillo's theme NAME.
203- **Quick add has ONE parser and it is the Go one.** The live pane is a
204 fetch to `/events/quick/parse`. Never add a second parser in
205 JavaScript: two readings of "next Tuesday at half three" means the
206 time shown is not the time saved.
207- **Attendees are recorded, never invited.** Nothing emails them, every
208 surface that names one says so, and their addresses are sealed with a
209 KEYED fingerprint for lookup β€” an unkeyed hash of an address is that
210 address in a different alphabet.
211- **Except a BOOKER, who typed their address to be mailed.** Booking
212 mail is the one outbound to a non-team address.
213
214**Browser**
215- `make browser` is the painted-output gate: Playwright, run from
216 `TestQuickAddInARealBrowser`. It is NOT in `make ci` β€” the one gate
217 everybody runs must not need a toolchain the app does not use.
218- It runs the feature twice, the second time with **JavaScript
219 disabled**. That pass is the only proof the scriptless floor is real.
220- `make browser` also runs `TestNoZoomOnTap`, through chromedp: every
221 text control on a touch screen is 16px or more, or iOS zooms the page
222 on tap. chromedp drives when `RASTRILLO_CHROME` names a Chromium (the
223 Makefile finds the Playwright cache's on a Mac); it parked at
224 `about:blank` without one. `TestBrowserWalk` drives now, but fails
225 at its first sign-in (no link is mailed after its form submit), and
226 is not in `make browser`; do not assume it ran.
227- **A control styled with `font: inherit` under a selector heavier than
228 a bare `input`** loses tokens.css's 16px-on-touch rule. Restate
229 `font-size: max(1rem, 1em)` for it under `(pointer: coarse)`, as
230 calendar.css does for the quick-add box.
231
232**rastrillo**
233- **rastrillo is `amadan.net/rastrillo/rastrillo`.** The old path,
234 `github.com/carlosframework/rastrillo`, stops at v0.23.0: code
235 importing it gets no fix, and its own checks cannot tell (Calendar
236 zoomed iOS on every tap for a month that way). `TestNoPreMoveRastrillo`
237 allows one import of it, native.go's bridge to rastrillo/native's
238 server, until that server moves; `native_exchange_test.go` proves the
239 bridge. The CLI is pinned as a `tool` in go.mod: `go tool rastrillo …`,
240 never a `rastrillo` from PATH.
241- **Run `go tool rastrillo doctor` on every rastrillo upgrade**, and
242 `--fix` to re-copy what drifted (tokens.css, select.js, datetime.js).
243 It leaves `vendoredIsMine` alone: rastrillo.js (estilo's shim, without
244 the old busy rule) and theme.css come from estilo through
245 `go run ./scripts/writeestilo`, and calendar.js is Calendar's OWN
246 script, not rastrillo's month-grid overlay of the same name. If
247 `--fix` ever writes rastrillo.js, re-run writeestilo: the old shim
248 must never load beside busy.js (TestBusyFilesAreEstilos). busy.js is
249 estilo's too, and shell.css/shell.js are rastrillo's console shell,
250 which Calendar does not draw. **While go.mod still requires the old
251 path, doctor compares nothing and `--fix` refuses**: copy the drifted
252 files out of the new module's `ui/` by hand, and
253 TestVendoredAssetsMatchTheLibrary says which.
254- **An emailed link confirms before it is spent.** GET /auth/verify draws
255 the door with one Sign in button (`handlers.renderConfirm`) and spends
256 nothing, so a mail scanner cannot burn it; the POST redeems. Both are
257 mounted in both modes. A test that signs in opens the link, reads the
258 token off the door and POSTs it (`SignInForTest`, `openLink`/`postLink`).
259- **Inline style attributes need the CSP's `'unsafe-inline'`.**
260 rastrillo's default style-src refuses them; the day and week grids
261 place chips and slots with them and every chip carries `--cal-hue`, so
262 `ContentSecurityPolicy` (serving.go) widens style-src alone.
263 `TestProductionCSPIsTheDefaultBarStyles` keeps every other directive
264 at rastrillo's default, and `TestPlacedChipsSurviveTheProductionCSP`
265 (`make browser`) is the one browser check that sees the real policy.
266 A CONSTANT style belongs in calendar.css, never in an attribute.
267
268**Logs**
269- **No title, place, note or address in any log line, error string or
270 metric label.** `carlos logs` is platform-held storage we cannot grep
271 afterwards. Errors carry a stage name and a code.
272
273**Deploys**
274- One binary, `CGO_ENABLED=0`, cgo-free SQLite.
275- **`carlos deploy`, never `carlos ship` alone.** `ship` leaves the
276 release unpromoted and nothing serving it.
277- **`make release`, never a bare `go build`** β€” it stamps
278 `rastrillo.BuildVersion` and refuses a dirty tree.
279- **A 200 is not verification, and neither is `X-Carlos-Version`.** That
280 header is the router's adopted release. `GET /api/version` is served
281 by the process. Measured here at ~110 seconds behind the promote.
282- **`stable` needs a passkey step-up in the console**, and so does
283 moving an instance between `stable` and `edge`. This app runs on
284 `edge`, like docs. Create an instance on the channel you want.
285
286---
287
288## Shipping cadence
289
290Worktree per piece of work, branch per piece of work, never on `main`.
291Squash-merge only when the work is done, it is approved, and CI is
292actually green β€” checked, not assumed. Review on a deployed canary,
293never localhost.
294
295`make ci` is the one gate: vet, gofmt, tests, migration check. The same
296definition CI runs. Run it before every push.
297
298## Review
299
300Every spec and every branch is reviewed by the highest-capability model
301available, discovered rather than hardcoded. Record which model
302reviewed. A review by a weaker model than the author is not a review.
303
304## Authorship
305
306πŸ€– / πŸ‘¨ markers, `Co-Authored-By:` trailers. AI is a guest with a name
307tag: opt-in, disclosed, and every path works with it switched off.
308
309Startup verifies the configured data key against `meta.sealed_key_check_v1`, or existing encrypted calendar names on first adoption. A refusal requires restoring the correct key/database pair. Never delete or replace the sentinel to make an unreadable database start.
310
311## Work in a worktree, and catch main up first
312
313Take a worktree for the work. Do not work in the shared checkout.
314
315 git -C <repo> fetch origin
316 git -C <repo> worktree add ../<repo>-<task> -b <branch> origin/main
317
318Both halves matter, for different reasons.
319
320**The worktree**, because several sessions share this machine and some
321share this repository, and a checkout has one HEAD, one index and one
322working tree between all of them. A branch switch under somebody else's
323uncommitted work destroys it with nothing on screen to say so, and two
324sessions editing the same paths disagree quietly instead of conflicting
325loudly. A worktree gives the work its own HEAD and its own files, and
326costs a directory.
327
328**From `origin/main`, after a fetch** β€” not from whatever the checkout
329happens to be sitting on. A branch cut from a stale main is a merge
330conflict scheduled for later, against files somebody else has renamed in
331the meantime. A branch cut from another task's branch is worse: it
332carries that task's commits into yours, where they are reviewed as
333though you wrote them, and the two can only be separated by hand.
334
335Remove it when the work has landed β€” `git worktree remove` β€” so that
336`git worktree list` keeps saying something true.
337
338Written down because of 2026-09-17, in `meet`. The shared checkout was
339sitting on one task's branch while holding a *different* task's feature
340uncommitted on disk β€” some four hundred lines, a new migration among
341them β€” put there by a session nobody else knew was working in that tree.
342Two other sessions were meanwhile coordinating over the same checkout by
343asking each other not to touch it, which is the arrangement a worktree
344makes unnecessary. Nothing was lost. Nothing about the arrangement would
345have said so if it had been, and by then the same feature existed twice,
346once on each side of a rename, because the two sessions had branched
347from different starting points.
348