# 🤖 Calendar

A calendar for one team, on one instance. Alongside `oficina/docs` and
`oficina/sheets`, on the same platform, in the same shape.

This file is binding. Where it disagrees with a general skill, this file
wins. Where it disagrees with the design spec, the spec wins and this
file is wrong — fix it in the same commit.

**The design:** `docs/superpowers/specs/2026-09-02-calendar-design.md`.
The raw material it came from, including the rejected alternatives, is
`docs/superpowers/brainstorms/2026-09-02-calendar-brainstorm.md`.

---

## The one rule

> **An event's contents are sealed at rest. The bucket never holds a
> plaintext title, place or note.**

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 zones — **and who is on the team.
Never what any event is, where it is, or what was written about it.**

`TestTheDatabaseNeverHoldsAPlaintextTitle` enforces it from the first
commit, and it greps the raw SQLite bytes and the WAL rather than
asking SQLite: a column that was never sealed looks identical through
Go and completely different on disk. It checkpoints first, or it would
pass by reading a file the data has not reached.

### What this rule does NOT cover, said here so it is never read as covering it

1. **Times are plaintext, and free/busy is a real leak.** Every view is
   a range query, `NextDue` reads `MIN(due_at)` before any request, and
   the platform's own timer registry holds reminder instants anyway.
   Sealing the instants would claim a property the estate does not
   have. A published database gives up the team's whole free/busy.
2. **A reminder email carries the title** through the relay and into a
   mailbox. That is delivery, not storage, and it is the product
   working — but the relay's logs are not ours.
3. **A subscription feed puts plaintext in whoever's account it was
   pasted into.** The share page says so before the button, not after.
4. **A compromised live host reads everything.** We never claim "we
   cannot read your data."
5. **A PUBLISHED booking page is a live free/busy oracle for that host,
   to anyone, without the database.** Every calendar the host can see
   shapes the answer. This is a NEW disclosure, not the one above, and
   the Publish button says so in a sentence before it is pressed.

---

## Settled decisions

| # | Ruling | Rejected | Who, when |
|---|---|---|---|
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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") |
| 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 |
| 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 |
| 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 |
| B2 | Everything the host can see is busy — every calendar they hold a grant on | Personal only | Booking spec §1, 2026-09-07 |
| 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 |
| 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 |
| B8 | A held slot is a tentative EVENT | A holds table the slot query consults | Booking spec §1, 2026-09-07 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| B16 | No sender error is "definite"; nothing is freed because a send failed | Marking holds failed | Booking spec §1, 2026-09-07 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| 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 |
| B24 | Reschedule repairs the event's reminder queue rows INSIDE its transaction | After commit | Booking spec §1, 2026-09-07 |
| 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 |
| 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 |

Don't relitigate one in a drive-by.

---

## Mechanical rules

Rules here are enforced by tests, not by memory. If you find yourself
relying on remembering one, make it a test instead.

**Time**
- **The record is `start_local` + `tz`. The UTC columns are an index.**
  Anything that writes a UTC column without deriving it from the local
  pair is a bug.
- **DST has no correct answer and Go does not promise one.** `clock.go`
  documents what actually happens and `clock_test.go` pins it to real
  unix numbers. The gap and the fold resolve DIFFERENTLY — the gap takes
  the pre-transition offset, the fold takes the second occurrence. Do
  not reason about one from the other.
- **A day is 23, 24 or 25 hours.** Use `DayBounds`, never `Add(24h)`.
- **`Zone()` takes bounded input on public routes:** `CommonZones` plus
  the host's own zone. Its cache holds successes only.
- **A booking slot is the same length on the wall clock and the elapsed
  clock, or it is not offered.** Both ends round-trip through `ToUTC`.
- **Every range is half-open.** An inclusive end is how a one-day event
  shows on two days and how a week query double-counts its boundary.
- **All-day events are DATES.** Never a datetime at UTC midnight, which
  puts a birthday on the wrong day west of Greenwich.

**Recurrence**
- **COUNT counts GENERATED occurrences; EXDATE removes afterwards.**
- **`Expand` may stop early.** Anything that decides FREE time reads
  `EventsInRangeStrict` and fails closed on `ErrIncompleteExpansion`.
- **A day span is a difference of CIVIL dates, never elapsed hours over
  24.** And advancement carries the series' ORIGINAL clock onto each
  new date — never the previous occurrence's resolved time.
- **A named date a month does not have is NO occurrence** — never
  clamped. The 31st, 29 February, the fifth Friday.
- **Overrides are keyed by the occurrence's ORIGINAL local time and
  rendered at their new one.** The range query is two passes because of
  it. Skip the second and a moved occurrence vanishes; skip the dedupe
  and it appears twice.
- Expansion iterates in **wall clock**. Stepping a UTC instant moves a
  09:00 standup by an hour for half the year.

**Data**
- Every scoped read goes through a grant join. **A row that isn't yours
  404s, never 403s** — including for a write, so status codes cannot
  enumerate the team's calendars.
- **Booker name, address, note and TOKEN are sealed; the address has
  the keyed fingerprint. A page's public code is generated, never a
  chosen slug — a slug is prose. Booking windows are plaintext:
  shape-of-time.**
- **Every booking transition is a conditional UPDATE naming the state
  and revision it expects, checking RowsAffected.** Zero rows is
  "someone moved it first", never a retry.
- **A booked event's start, end, tz, all_day, kind and rrule belong to
  the booking flow.** A host edit that changes any of them is refused;
  the form shows them disabled with the stored values hidden beneath.
- **Never bind a request body onto a model.** Updates take an explicit
  column allowlist; the columns absent from it (`calendar_id`,
  `created_by`, `series_id`) are exactly the ones a hidden field must
  not move.
- **Never `sessions.UserID`** — under the magic-link plugin the Subject
  is an address, so it returns `(0, false)` and every query silently
  scopes to person 0: a calendar that looks empty rather than broken.
  Use `auth.From(r)`, and `requirePerson` for the id.
- **A Home session subject is opaque.** Resolve its explicitly linked person ID
  before any grant lookup. Never pass it to `EnsurePersonSetup` or use it as an
  address. Recheck current admission and retain AUTOINCREMENT person IDs without
  reuse or reassignment. Home proof storage uses a separate stable connection key.
- Migrations are additive-only. Never edit one that has shipped.

**Ticks**
- **A `time.Ticker` is not a scheduler.** The instance hibernates.
- **`NextDue` is the earliest CLAIMABLE instant** across reminders, hold
  expiries, owed booking mail and IP-forgets. New scheduled work goes
  here, never in a goroutine. Cheap work runs before delivery;
  reminders get the first half of the delivery budget.
- **Every queue row has a random `claim_id`;** every completion
  mutation is conditional on it and checks RowsAffected, and a payload
  is built from the row the claim returned, never from the candidate
  read. A reminder's `due_at` is the occurrence minus the offset and is
  never written by a retry — `retry_at` is. Booking mail's `due_at` is
  retry eligibility only.
- **No sender error is definite.** Never free a resource because a send
  "failed". Every send goes through `boundedSender`; `ErrMailBusy` is a
  deferral, not an attempt.
- **A reschedule repairs the event's queue rows in the same transaction
  as the move.**
- **The tick route is mounted OUTSIDE `csrf.Protect`.** `csrf.Protect`
  refuses any POST with no `Sec-Fetch-Site`, `Origin` or `Referer`, and
  the scheduler sends none; inside the protected router a tick 403s
  forever and the platform reads 4xx as "do not retry". It fails
  silently and permanently. `TestTheTickRouteIsNotBehindCSRF` stands in
  front of it and asserts WHICH refusal answered.
- **Work inside the request.** 202-plus-a-goroutine is a reliable way to
  be hibernated mid-send.
- **Ticks are expensive.** `CARLOS_HIBERNATE_IDLE` defaults to 15
  minutes, so each tick buys a quarter hour of billed residency. The
  declared schedule is 6h and the precision comes from `NextDue`.

**Frontend**
- Write the **`rst-*` attribute grammar** (`rst-btn="primary"`), never
  the class spelling. Adopt per screen, never per element.
- This app's own components take **`cal-`**.
- **No affordance may require script.** The scriptless page is the
  product; `datetime.js` and any later enhancement sit on top of a
  working floor.
- **Colour never carries meaning alone.** Every chip prints its own
  time and title; cancelled says "Cancelled"; past is written in the
  past tense; today has three signals.
- Light/dark is three states and **auto is the ABSENCE of
  `data-theme`**. The attribute takes the light/dark SCHEME, never
  rastrillo's theme NAME.
- **Quick add has ONE parser and it is the Go one.** The live pane is a
  fetch to `/events/quick/parse`. Never add a second parser in
  JavaScript: two readings of "next Tuesday at half three" means the
  time shown is not the time saved.
- **Attendees are recorded, never invited.** Nothing emails them, every
  surface that names one says so, and their addresses are sealed with a
  KEYED fingerprint for lookup — an unkeyed hash of an address is that
  address in a different alphabet.
- **Except a BOOKER, who typed their address to be mailed.** Booking
  mail is the one outbound to a non-team address.

**Browser**
- `make browser` is the painted-output gate: Playwright, run from
  `TestQuickAddInARealBrowser`. It is NOT in `make ci` — the one gate
  everybody runs must not need a toolchain the app does not use.
- It runs the feature twice, the second time with **JavaScript
  disabled**. That pass is the only proof the scriptless floor is real.
- `make browser` also runs `TestNoZoomOnTap`, through chromedp: every
  text control on a touch screen is 16px or more, or iOS zooms the page
  on tap. chromedp drives when `RASTRILLO_CHROME` names a Chromium (the
  Makefile finds the Playwright cache's on a Mac); it parked at
  `about:blank` without one. `TestBrowserWalk` drives now, but fails
  at its first sign-in (no link is mailed after its form submit), and
  is not in `make browser`; do not assume it ran.
- **A control styled with `font: inherit` under a selector heavier than
  a bare `input`** loses tokens.css's 16px-on-touch rule. Restate
  `font-size: max(1rem, 1em)` for it under `(pointer: coarse)`, as
  calendar.css does for the quick-add box.

**rastrillo**
- **rastrillo is `amadan.net/rastrillo/rastrillo`.** The old path,
  `github.com/carlosframework/rastrillo`, stops at v0.23.0: code
  importing it gets no fix, and its own checks cannot tell (Calendar
  zoomed iOS on every tap for a month that way). `TestNoPreMoveRastrillo`
  allows one import of it, native.go's bridge to rastrillo/native's
  server, until that server moves; `native_exchange_test.go` proves the
  bridge. The CLI is pinned as a `tool` in go.mod: `go tool rastrillo …`,
  never a `rastrillo` from PATH.
- **Run `go tool rastrillo doctor` on every rastrillo upgrade**, and
  `--fix` to re-copy what drifted (tokens.css, select.js, datetime.js).
  It leaves `vendoredIsMine` alone: rastrillo.js (estilo's shim, without
  the old busy rule) and theme.css come from estilo through
  `go run ./scripts/writeestilo`, and calendar.js is Calendar's OWN
  script, not rastrillo's month-grid overlay of the same name. If
  `--fix` ever writes rastrillo.js, re-run writeestilo: the old shim
  must never load beside busy.js (TestBusyFilesAreEstilos). busy.js is
  estilo's too, and shell.css/shell.js are rastrillo's console shell,
  which Calendar does not draw. **While go.mod still requires the old
  path, doctor compares nothing and `--fix` refuses**: copy the drifted
  files out of the new module's `ui/` by hand, and
  TestVendoredAssetsMatchTheLibrary says which.
- **An emailed link confirms before it is spent.** GET /auth/verify draws
  the door with one Sign in button (`handlers.renderConfirm`) and spends
  nothing, so a mail scanner cannot burn it; the POST redeems. Both are
  mounted in both modes. A test that signs in opens the link, reads the
  token off the door and POSTs it (`SignInForTest`, `openLink`/`postLink`).
- **Inline style attributes need the CSP's `'unsafe-inline'`.**
  rastrillo's default style-src refuses them; the day and week grids
  place chips and slots with them and every chip carries `--cal-hue`, so
  `ContentSecurityPolicy` (serving.go) widens style-src alone.
  `TestProductionCSPIsTheDefaultBarStyles` keeps every other directive
  at rastrillo's default, and `TestPlacedChipsSurviveTheProductionCSP`
  (`make browser`) is the one browser check that sees the real policy.
  A CONSTANT style belongs in calendar.css, never in an attribute.

**Logs**
- **No title, place, note or address in any log line, error string or
  metric label.** `carlos logs` is platform-held storage we cannot grep
  afterwards. Errors carry a stage name and a code.

**Deploys**
- One binary, `CGO_ENABLED=0`, cgo-free SQLite.
- **`carlos deploy`, never `carlos ship` alone.** `ship` leaves the
  release unpromoted and nothing serving it.
- **`make release`, never a bare `go build`** — it stamps
  `rastrillo.BuildVersion` and refuses a dirty tree.
- **A 200 is not verification, and neither is `X-Carlos-Version`.** That
  header is the router's adopted release. `GET /api/version` is served
  by the process. Measured here at ~110 seconds behind the promote.
- **`stable` needs a passkey step-up in the console**, and so does
  moving an instance between `stable` and `edge`. This app runs on
  `edge`, like docs. Create an instance on the channel you want.

---

## Shipping cadence

Worktree per piece of work, branch per piece of work, never on `main`.
Squash-merge only when the work is done, it is approved, and CI is
actually green — checked, not assumed. Review on a deployed canary,
never localhost.

`make ci` is the one gate: vet, gofmt, tests, migration check. The same
definition CI runs. Run it before every push.

## Review

Every spec and every branch is reviewed by the highest-capability model
available, discovered rather than hardcoded. Record which model
reviewed. A review by a weaker model than the author is not a review.

## Authorship

🤖 / 👨 markers, `Co-Authored-By:` trailers. AI is a guest with a name
tag: opt-in, disclosed, and every path works with it switched off.

Startup 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.

## Work in a worktree, and catch main up first

Take a worktree for the work. Do not work in the shared checkout.

    git -C <repo> fetch origin
    git -C <repo> worktree add ../<repo>-<task> -b <branch> origin/main

Both halves matter, for different reasons.

**The worktree**, because several sessions share this machine and some
share this repository, and a checkout has one HEAD, one index and one
working tree between all of them. A branch switch under somebody else's
uncommitted work destroys it with nothing on screen to say so, and two
sessions editing the same paths disagree quietly instead of conflicting
loudly. A worktree gives the work its own HEAD and its own files, and
costs a directory.

**From `origin/main`, after a fetch** — not from whatever the checkout
happens to be sitting on. A branch cut from a stale main is a merge
conflict scheduled for later, against files somebody else has renamed in
the meantime. A branch cut from another task's branch is worse: it
carries that task's commits into yours, where they are reviewed as
though you wrote them, and the two can only be separated by hand.

Remove it when the work has landed — `git worktree remove` — so that
`git worktree list` keeps saying something true.

Written down because of 2026-09-17, in `meet`. The shared checkout was
sitting on one task's branch while holding a *different* task's feature
uncommitted on disk — some four hundred lines, a new migration among
them — put there by a session nobody else knew was working in that tree.
Two other sessions were meanwhile coordinating over the same checkout by
asking each other not to touch it, which is the arrangement a worktree
makes unnecessary. Nothing was lost. Nothing about the arrangement would
have said so if it had been, and by then the same feature existed twice,
once on each side of a rename, because the two sessions had branched
from different starting points.
