All docs

Docs · Reference

Calendar

CalDAV and ICS feeds as the UI

The calendar personality serves a durable calendar store to any CalDAV client — Apple Calendar, iOS, Thunderbird — and as an ICS feed anyone can subscribe to (Google Calendar, Outlook). Stacks put events into it with an op; the client reads them. The head is a generic calendar server: it knows no product and never runs a stack to answer a read.

Nothing lands in a calendar on its own. A rule that wants an event to show up in a client calls txco://calendar/put with the event it chose to publish — a structured object the chassis renders to iCalendar, or the iCalendar text itself — and the head serves it from the store. The wire form is derived; the event is what is retained.

The other direction is a stack hearing what the client did: every committed mutation (an event dragged to a new hour, a new event, a delete) can reach the tenant’s _calendar stack after the fact, and a calendar can be set to ask the stack first — and to accept the stack’s rewrite of the client’s event. That is how “drag the pony’s schedule and the pony reschedules” is written in txcl, not in Go.

Turn it on

txco serve --personalities cron,web,admin,calendar

There is no listener of its own: the head mounts on the web head under a reserved path prefix on every hostname it serves — --calendar-path-prefix (default /dav) plus /.well-known/caldav, which redirects into it. TLS is the web head’s (a front proxy, or --web-tls-addr). The index lives in its own SQLite file (--calendar-db-path, default ./chassis/data/calendar.db); objects are small text and live in the index, never the blob CAS.

On a fleet, --calendar-store selects a backend an overlay registers (the hosted build ships postgres, reading TXCO_DB_AUTH_DSN). A shared backend is opened on every node, head or not, so txco://calendar/* on any node project into the one index the head serves; a node whose open failed at boot answers txco_calendar_disabled until restart.

For local development:

txco dev --calendar       # http://<dev host>:<web port>/dav/, Basic auth over plaintext

Add the account in Calendar (macOS) with Other CalDAV Account → Advanced: server = the bound host, port = the web port, SSL off, path /dav/; in Thunderbird, a network calendar at http://<host>:<port>/dav/<username>/calendars/<name>/. Every Basic-auth attempt logs one calendar login line with its outcome.

Accounts: txco://calendar/account

EXEC "txco://calendar/account"
  WITH username = "paris@pony.example.com",   # <local>@<domain the tenant owns>
       password = ._imapacct.password,        # the same password the IMAP account got
       into = "_calacct"
WITHMeaning
username (req)<local>@<domain>. The domain must be a verified hostname binding or a delegated DNS zone of the tenant — the ownership rule txco://sendmail and txco://imap/account apply. Usernames are global: one tenant per address.
passwordOmitted: unchanged on update, generated on create. "": generated. Otherwise stored (≥ 8 chars). Only an argon2id hash is kept. Pass the password another head minted and one credential opens both.
rotatetrue: generate a new password for an existing account and return it once.
password_style / password_wordsAs IMAP: token (default) or words (4–12 BIP-39 words, default 5).
statusactive (default) or disabled.
policyAccount-default mutation policy (below), the fallback for every calendar.

Result at into (default _calendar): {username, created, principal, password?, rotated?}password appears only when it was generated, and only this once. principal is the account’s CalDAV path.

Calendars: txco://calendar/calendar

EXEC "txco://calendar/calendar"
  WITH username     = "paris@pony.example.com",
       name         = "schedule",
       display_name = "Paris schedule",
       timezone     = "Europe/Paris",
       policy       = &object("put", "stack", "delete", "stack"),
       feed         = "ensure",
       into         = "_sched"
WITHMeaning
username, name (req)The account and the calendar’s path segment ([A-Za-z0-9._~-], up to 128 chars). Creates when absent, updates otherwise; empty fields are left alone.
display_name, description, color (#rrggbb), sort_order, timezone (IANA)What the client shows.
policyPer-calendar mutation policy (below).
feedensure (mint a feed token if the calendar has none), rotate (mint a new one, retiring the old), disable.
removetrue: soft-delete the calendar and tombstone its objects.

Result: {id, name, path, display_name, description, color, timezone, policy, feed, sync_token, created, feed_token?, feed_path?, removed?}. feed_token appears only when minted, and only this once; only its hash is stored. feed_path is <prefix>/feed/<token>.ics — prepend the stack’s own https://<host> and that URL is the subscription.

Objects: txco://calendar/put

EXEC "txco://calendar/put"
  WITH username = "paris@pony.example.com",
       calendar = "schedule",
       name     = "daily-digest.ics",         # the resource name on create
       event    = &object(
         "summary",     "Daily digest",
         "description", .question,
         "start",       "2026-01-01T09:00:00Z",
         "duration",    "PT30M",
         "rrule",       "FREQ=DAILY"),
       into     = "_put"
WITHMeaning
username, calendarThe account and the calendar (its name).
event{} or icalThe structured event (the chassis renders a canonical VEVENT) or iCalendar text (the UID is the bytes’ own). Not both.
uidThe object’s identity. Optional with event{}: derived from name as <name>.<local>@<domain>, stable across re-materializations.
nameResource name on create; on update the object keeps the name it has, whatever the client chose.

event{} is generic iCalendar vocabulary: uid, summary, description, location, status (CONFIRMED / TENTATIVE / CANCELLED), url, start, end or duration (default PT1H), tzid, rrule (an RFC 5545 RECUR value), exdate[]. start/end are YYYY-MM-DD (all-day), RFC3339 (with Z or an offset, stored as UTC), or a local YYYY-MM-DDTHH:MM:SS with tzid — a VTIMEZONE is emitted from the chassis’s own zone data, so “08:00 Paris every weekday” survives daylight saving. Floating times are refused.

Result: {name, path, uid, etag, created, noop, sequence, modseq}. The same content again (DTSTAMP and SEQUENCE aside) is a noop — an hourly re-materialization never changes an etag; changed content gets a higher SEQUENCE. The op charges fuel per MiB like blob/put. Errors land as <into>.error.{code, message} with the run continuing: txco_calendar_disabled, txco_calendar_no_account, txco_calendar_no_calendar, txco_calendar_no_object, txco_calendar_domain_not_owned, txco_calendar_username_taken, txco_calendar_invalid_arg, txco_calendar_too_large, txco_calendar_conflict (the UID names another resource).

The rest of the op family

OpWITHReturns
txco://calendar/getusername, calendar, uid or name{name, path, uid, etag, component, size, summary, start_utc, end_utc, recurs, sequence, modseq, updated_at, ical, event{}}event is the parse: every input field plus all_day, start_utc, end_utc, recur{freq, interval, byday[], …}, sequence, dtstamp
txco://calendar/listusername{calendars:[{…, objects}], count, home}
txco://calendar/listusername, calendar, after (modseq cursor), limit (≤ 1000){items:[{name, path, uid, etag, summary, start_utc, recurs, modseq, …}], count, next, sync_token}
txco://calendar/deleteusername, calendar, uid or name{deleted, name, uid}

What a client gets

Discovery through /.well-known/caldav<prefix>/ (current-user- principal) → <prefix>/<username>/ (calendar-home-set) → <prefix>/<username>/calendars/ (the calendars) — so a client needs the server, the username and the password. Then PROPFIND, REPORT (calendar-query with time ranges — recurrences are expanded — and calendar-multiget), GET, PUT (with If-Match / If-None-Match), DELETE, MKCALENDAR, PROPPATCH (display name, description, colour, order). Objects keep stable URLs, UIDs and ETags across re-materialization. Not yet: sync-collection / getctag (clients fall back to an etag diff per refresh, which works), RECURRENCE-ID overrides in time-range matching, VTODO in feeds, invitations, free/busy.

Basic auth on every request: the head verifies argon2id once and caches the verified triple for five minutes, so a client’s dozens of requests per refresh cost one hash. A request must arrive over TLS — the web head’s own listener, or X-Forwarded-Proto: https from the front proxy — unless --calendar-insecure-auth (dev). Throttles count cache misses only (--calendar-login-rate, per client IP and per username). A disabled account and a suspended tenant are refused; an account on another tenant’s hostname is refused exactly like a wrong password.

The ICS feed GET <prefix>/feed/<token>.ics needs no auth — the token is the secret (24 random bytes, hashed at rest, shown once by the op). It renders every live object with an ETag and Cache-Control: private, max-age=<--calendar-feed-max-age>; If-None-Match answers 304. Feeds are off until a stack asks for one.

Seeding calendars with a stack

A stack may ship whole calendars declaratively in the reserved CALENDARS/ tree, the fourth pack kind beside VECTORS/, KV/ and BLOBS/ (deployed by txco data apply, mirrored live by txco dev):

OPS/<stack>/
  CALENDARS/
    paris@pony.example.com/events.jsonl     → the calendar "events" of that account

Each file is NDJSON and owns one calendar of one account:

{"calendar":{"display_name":"Paris events","timezone":"Europe/Paris","color":"#2a9d8f"}}
{"name":"welcome.ics","event":{"summary":"Welcome","start":"2026-09-04T18:00:00","tzid":"Europe/Paris","duration":"PT1H"}}
{"name":"visit.ics","event":{"summary":"Visit","start":"2026-09-05","end":"2026-09-06"}}
{"name":"notice.ics","ical":"BEGIN:VCALENDAR\r\n…"}

The optional calendar line sets the display fields (and policy); every other line is one object — event{} as txco://calendar/put takes it (the uid defaults to <name>.<local>@<domain>) or ical text. On activation the calendar is ensured, every object put (unchanged content is a no-op, changed content gets a higher SEQUENCE), and every live object the pack no longer lists is deleted — the pack is the calendar’s desired state. The account must already exist (only txco://calendar/account mints a password); a pack for an unknown account is an error for that pack, logged, activation unaffected, retried on the next apply. A calendar the pack creates denies client put/delete unless its header says otherwise: a client edit in a pack-owned calendar would be undone by the next apply, so keep runtime-written calendars out of packs and give pack-owned ones their own name.

Policy: what a stack hears, and when

Per calendar, five verbs — put, delete, mkcalendar (a client creates a calendar), remove (a client deletes one), proppatch — each one of:

ModeEffect
deny403 at the protocol layer, no round trip (the default for mkcalendar and remove)
localprotocol state only, no event (the default for proppatch)
observecommit, then tell the _calendar stack, fire-and-forget (the default for put and delete)
stackask the _calendar stack first; 403 unless it answers @calendar.res.ok = true

Resolution: the calendar’s policy, then the account’s policy, then the chassis default. The _calendar stack is the subscription — while it exists, events flow; without it observe is silent and stack answers 503.

The envelope

@src                "calendar"                          @client.ip
@calendar.tenant    (slug)         @calendar.account    (username)
@calendar.phase     observe | answer                    @calendar.op   put | delete | mkcalendar | remove | proppatch
@calendar.calendar  {id, name, display_name, timezone}
@calendar.object    {name, uid, etag, prior_etag, component, size, exists}   (put / delete)
@calendar.ical      the client's object, canonical iCalendar text            (put)
@calendar.event     {…the parse of it: uid, summary, start, tzid, start_utc, recur{…}, …}
@calendar.prior     {event: {…the stored object's parse…}}                   (put on an existing object, delete)
@calendar.props     {displayname, description, color, order, timezone}      (mkcalendar / proppatch)

The object’s bytes ride the envelope (they are small); a rule may EMIT @delete = &array("@calendar.ical", "@calendar.event", "@calendar.prior") once it has consumed them.

Answering (@calendar.phase == "answer")

WHEN @calendar.phase == "answer" && @calendar.event.recur.freq != "DAILY"
  EMIT @calendar.res.ok = false, @calendar.res.code = "cannot",
       @calendar.res.msg = "this schedule runs daily; weekly rules come later"

WHEN @calendar.phase == "answer" && @calendar.op == "put"
  EMIT @calendar.res.ok = true,
       @calendar.res.event = &object("summary", @calendar.event.summary,
                                     "start", "2026-01-01T09:00:00Z",
                                     "duration", "PT30M", "rrule", "FREQ=DAILY")

ok absent or false is a 403; code is cannot (403), limit (507) or unavailable (503); msg is shown to the client. On ok, an optional @calendar.res.event (or .ical) is the object the head commits instead of the client’s bytes — the client’s UID is kept, so its resource keeps its identity — which is how a stack normalizes an edit to what it can actually run. The head waits --calendar-resp-timeout (30 s); past it the client gets 503 and a late answer is discarded.

Observe-lane runs are sampled and bounded (--calendar-observe-sample, --calendar-observe-max-inflight); a full queue drops the observation rather than delay a client. Both lanes meter fuel like any run.

Client settings

The head never routes by name: it serves every hostname the web head does, so the server a calendar client should use is the domain of the addressparis@<stack>.stacks.example connects to https://<stack>.stacks.example/dav/, and /.well-known/caldav on that host does the rest. A user name typed without its domain (paris) completes to the server’s host. For clients that discover a server from an address by DNS (RFC 6764), the dns personality can publish _caldavs._tcp SRV + TXT records (--dns-caldavs-port, see dns).

Flags

FlagDefaultMeaning
--calendar-path-prefix/davThe reserved prefix on every hostname (plus /.well-known/caldav)
--calendar-store / --calendar-db-pathsqlite / ./chassis/data/calendar.dbThe index; a non-sqlite backend is shared and opened on every node
--calendar-insecure-authfalseAccept Basic auth without TLS (txco dev --calendar sets it)
--calendar-login-rate30Verifications per minute, per IP and per username, on cache misses only
--calendar-object-max-bytes1 MiBSize cap for an object (ops and client PUT; advertised as max-resource-size)
--calendar-feed-max-age300Cache-Control max-age on feeds
--calendar-resp-timeout30sAnswer-lane deadline
--calendar-observe-sample / --calendar-observe-max-inflight1 / 8Observe-lane sampling and concurrency

Env: TXCO_CALENDAR_*. Example: examples/calendar-hello.

Edit this page · View as markdown