oficina / calendar Public
π€ 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
- Times are plaintext, and free/busy is a real leak. Every view is a range query,
NextDuereadsMIN(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. - 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.
- A subscription feed puts plaintext in whoever's account it was pasted into. The share page says so before the button, not after.
- A compromised live host reads everything. We never claim "we cannot read your data."
- 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, allday, 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 `claimid; every completion mutation is conditional on it and built from the row the claim returned; reminders retry through retryat, never dueat; 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.godocuments what actually happens andclock_test.gopins 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, neverAdd(24h). Zone()takes bounded input on public routes:CommonZonesplus 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.
Expandmay stop early. Anything that decides FREE time readsEventsInRangeStrictand fails closed onErrIncompleteExpansion.- 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. Useauth.From(r), andrequirePersonfor the id. - A Home session subject is opaque. Resolve its explicitly linked person ID before any grant lookup. Never pass it to
EnsurePersonSetupor 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.Tickeris not a scheduler. The instance hibernates. NextDueis 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'sdue_atis the occurrence minus the offset and is never written by a retry βretry_atis. Booking mail'sdue_atis retry eligibility only. - No sender error is definite. Never free a resource because a send "failed". Every send goes through
boundedSender;ErrMailBusyis 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.Protectrefuses any POST with noSec-Fetch-Site,OriginorReferer, 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.TestTheTickRouteIsNotBehindCSRFstands 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_IDLEdefaults to 15 minutes, so each tick buys a quarter hour of billed residency. The declared schedule is 6h and the precision comes fromNextDue.
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.jsand 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 browseris the painted-output gate: Playwright, run fromTestQuickAddInARealBrowser. It is NOT inmake 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 browseralso runsTestNoZoomOnTap, through chromedp: every text control on a touch screen is 16px or more, or iOS zooms the page on tap. chromedp drives whenRASTRILLO_CHROMEnames a Chromium (the Makefile finds the Playwright cache's on a Mac); it parked atabout:blankwithout one.TestBrowserWalkdrives now, but fails at its first sign-in (no link is mailed after its form submit), and is not inmake browser; do not assume it ran.- A control styled with
font: inheritunder a selector heavier than a bareinputloses tokens.css's 16px-on-touch rule. Restatefont-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).TestNoPreMoveRastrilloallows one import of it, native.go's bridge to rastrillo/native's server, until that server moves;native_exchange_test.goproves the bridge. The CLI is pinned as atoolin go.mod:go tool rastrillo β¦, never arastrillofrom PATH. - Run
go tool rastrillo doctoron every rastrillo upgrade, and--fixto re-copy what drifted (tokens.css, select.js, datetime.js). It leavesvendoredIsMinealone: rastrillo.js (estilo's shim, without the old busy rule) and theme.css come from estilo throughgo run ./scripts/writeestilo, and calendar.js is Calendar's OWN script, not rastrillo's month-grid overlay of the same name. If--fixever 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--fixrefuses: copy the drifted files out of the new module'sui/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, soContentSecurityPolicy(serving.go) widens style-src alone.TestProductionCSPIsTheDefaultBarStyleskeeps every other directive at rastrillo's default, andTestPlacedChipsSurviveTheProductionCSP(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 logsis 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, nevercarlos shipalone.shipleaves the release unpromoted and nothing serving it.make release, never a barego buildβ it stampsrastrillo.BuildVersionand 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/versionis served by the process. Measured here at ~110 seconds behind the promote. stableneeds a passkey step-up in the console, and so does moving an instance betweenstableandedge. This app runs onedge, 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.