Document the CardDAV references and the first milestone
TODO.md moved into the ranger backlog, so its items live alongside the
milestone tasks rather than in two places.
Assisted-by: Claude Opus 5 via Claude Code
diff --git a/README.md b/README.md
index 0ac47aa..e9f9c69 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
A CardDAV server for my family.
-## Simplifying Assumptions
+## Simplifying assumptions
- Small, trusted user group (2-3 people) — no permissions or access controls
- Network access via Tailscale — simple authentication
@@ -11,7 +11,104 @@ A CardDAV server for my family.
## Features
-- Per-user contact ownership with contacts/groups sharing
- Groups with attributes (e.g., an address shared by all members)
- Selective sync (choose which contacts to sync rather than all-or-nothing)
- Plaintext storage in git (version history, human-readable)
+
+## Status
+
+A read-only skeleton. Every response in `lib/pro_tacts/web.rb` is hardcoded:
+one principal, one address book, one vCard. Nothing is parsed, stored, or
+looked up yet. The goal of this stage is to get macOS Contacts to display a
+single card, so that later work has a known-good baseline to change.
+
+The hardcoded responses are **not** the minimal set macOS needs. They were
+assembled from what working servers send, so some properties are certainly
+unnecessary. Reducing them to the minimum is its own task, and until that
+happens, do not read the current output as a specification.
+
+## Protocol references
+
+CardDAV is a stack of extensions rather than a single specification, so
+implementing it means reading several RFCs together:
+
+| RFC | Title | Why it matters |
+|---|---|---|
+| [4918][rfc4918] | HTTP Extensions for WebDAV | `PROPFIND`, `Depth`, `207 Multi-Status`, ETags |
+| [3253][rfc3253] | Versioning Extensions to WebDAV | Defines `REPORT` and `supported-report-set` |
+| [5397][rfc5397] | WebDAV Current Principal Extension | `current-user-principal`, the entry point to discovery |
+| [6352][rfc6352] | CardDAV | Address book collections, `addressbook-multiget`, `addressbook-query` |
+| [6578][rfc6578] | Collection Synchronization for WebDAV | `sync-collection` REPORT and sync tokens |
+| [6764][rfc6764] | Locating Services for CalDAV and CardDAV | `/.well-known/carddav` and SRV-based discovery |
+| [2426][rfc2426] | vCard 3.0 | The version Apple clients actually speak |
+| [6350][rfc6350] | vCard 4.0 | The current version; Apple does not use it |
+
+[rfc4918]: https://datatracker.ietf.org/doc/html/rfc4918
+[rfc3253]: https://datatracker.ietf.org/doc/html/rfc3253
+[rfc5397]: https://datatracker.ietf.org/doc/html/rfc5397
+[rfc6352]: https://datatracker.ietf.org/doc/html/rfc6352
+[rfc6578]: https://datatracker.ietf.org/doc/html/rfc6578
+[rfc6764]: https://datatracker.ietf.org/doc/html/rfc6764
+[rfc2426]: https://datatracker.ietf.org/doc/html/rfc2426
+[rfc6350]: https://datatracker.ietf.org/doc/html/rfc6350
+
+RFC 6352 requires an address book collection to support vCard 3.0 and treats
+4.0 as optional, which is why the hardcoded card here is `VERSION:3.0` even
+though `docs/plans/2026-01-12-carddav-reference.md` shows 4.0 examples.
+
+Two properties macOS depends on are not in any RFC. They come from Apple's
+CalendarServer, which is archived but still the only written source:
+
+- [`getctag`][ctag] in the `http://calendarserver.org/ns/` namespace, a
+ collection-wide change tag. Without it, macOS never requests any vCards.
+- [`push-transports` and `pushkey`][pubsub], for server-initiated refresh.
+ Not needed, but macOS asks for them on every collection `PROPFIND`.
+
+[ctag]: https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-ctag.txt
+[pubsub]: https://github.com/apple/ccs-calendarserver/blob/master/doc/Extensions/caldav-pubsubdiscovery.txt
+
+## What macOS Contacts needs
+
+sabre/dav's [notes on the macOS Address Book client][sabre-osx] are the best
+single source of client quirks, and they explain several failures that look
+like protocol bugs but are not. See `docs/macos-contacts.md` for the details
+worth keeping close, including how to turn on the client's own debug logging.
+
+[sabre-osx]: https://sabre.io/dav/clients/osx-addressbook/
+
+## Reference implementations
+
+`servers/` holds compose files for CardDAV servers to compare against, each
+sitting behind mitmproxy because those servers cannot be made to log what we
+need. Point macOS Contacts at one, watch what it sends and what a working
+server sends back, then make pro-tacts match. pro-tacts itself is debugged
+through its own logs instead.
+
+Monica is the one confirmed working with macOS Contacts here, so prefer it
+when a recording needs to be trustworthy. Its issue tracker is full of
+reports of the opposite, which is worth knowing before taking them at face
+value: the two recurring causes in [monicahq/monica#4240][monica-4240] are a
+`/.well-known/carddav` redirect that downgrades HTTPS to HTTP, and Monica
+requiring an API token rather than a password. Neither is a CardDAV problem.
+The one genuinely protocol-level thread is the sabre/dav investigation that
+issue prompted, [sabre-io/dav#1315][sabre-1315], on macOS giving up when
+discovery answers at the wrong resource.
+
+[monica-4240]: https://github.com/monicahq/monica/issues/4240
+[sabre-1315]: https://github.com/sabre-io/dav/issues/1315
+
+Two implementations are worth reading rather than running:
+
+- [sabre/dav][sabre] is the reference PHP implementation and what Baikal,
+ Monica, and Nextcloud are all built on. Its client-quirk documentation is
+ more valuable than its code.
+- [Xandikos][xandikos] is a small Python CalDAV/CardDAV server backed by a
+ git repository, which makes it the closest existing thing to what
+ pro-tacts is trying to be. Its
+ [DAV compliance notes][xandikos-compliance] enumerate every method,
+ header, property, and report against the RFC that defines it — a useful
+ checklist for deciding what to skip.
+
+[sabre]: https://sabre.io/dav/building-a-carddav-client/
+[xandikos]: https://www.xandikos.org/
+[xandikos-compliance]: https://github.com/jelmer/xandikos/blob/master/notes/dav-compliance.rst
diff --git a/TODO.md b/TODO.md
deleted file mode 100644
index ed70a72..0000000
--- a/TODO.md
+++ /dev/null
@@ -1,5 +0,0 @@
-- [ ] Authentication
-- [ ] Disable Sentry in tests
-- [ ] Add type checking
-- [ ] Hide warnings when requiring Sentry (CGI)
-- [ ] Consolidate configuration
diff --git a/docs/macos-contacts.md b/docs/macos-contacts.md
new file mode 100644
index 0000000..034d6df
--- /dev/null
+++ b/docs/macos-contacts.md
@@ -0,0 +1,128 @@
+# macOS Contacts as a CardDAV client
+
+What the macOS Contacts client does, where it deviates from the RFCs, and
+which failures look like protocol bugs but are not. Most of this is
+distilled from sabre/dav's [client notes][sabre-osx] and from traffic
+captures against the servers in `servers/`.
+
+[sabre-osx]: https://sabre.io/dav/clients/osx-addressbook/
+
+Scope: client behavior only. For the specifications themselves, see the
+table in the README. For request and response shapes, see
+`2026-01-12-carddav-reference.md`.
+
+## Turn on the client's debug log first
+
+macOS 10.8 and later will log its own CardDAV traffic once asked:
+
+```sh
+defaults write com.apple.addressbook.carddavplugin EnableDebug -bool YES
+defaults write com.apple.addressbook.carddavplugin LogConnectionDetails -bool YES
+```
+
+Logs land in `~/Library/Logs/CardDAVPlugin`. This is faster than guessing
+from server logs, because the client makes decisions (giving up, downgrading,
+refusing a redirect) that never reach the server at all.
+
+## The account setup path
+
+In System Settings, add the account under Internet Accounts, Add Other
+Account, CardDAV account, with Account Type set to Manual. Manual matters:
+automatic setup runs its own discovery and fails in ways that are harder to
+read.
+
+The Server Address field takes a bare hostname, no scheme. Reports of
+working Monica setups differ on what belongs in Server Path — some use the
+principal URL, some the address book collection, some leave it empty and let
+`/.well-known/carddav` do the work. Leaving it empty is the case worth
+supporting, since it is the only one that exercises discovery.
+
+## The `.well-known` redirect is the most common failure
+
+Nearly every "macOS can't see my contacts" report against Monica traces back
+to `/.well-known/carddav`, not to CardDAV. The client will not follow a
+redirect that downgrades HTTPS to HTTP, so a reverse proxy that terminates
+TLS and then redirects using the request's own scheme sends the client to an
+`http://` URL, which it silently drops.
+
+Redirect to an absolute `https://` URL. Do not rely on the proxy passing the
+original scheme through, and do not chain redirects.
+
+## Discovery stops where the properties stop
+
+macOS issues a `PROPFIND` with `Depth: 0` against the configured URL asking
+for this set:
+
+```xml
+<A:propfind xmlns:A="DAV:">
+ <A:prop>
+ <B:addressbook-home-set xmlns:B="urn:ietf:params:xml:ns:carddav"/>
+ <B:directory-gateway xmlns:B="urn:ietf:params:xml:ns:carddav"/>
+ <A:displayname/>
+ <C:email-address-set xmlns:C="http://calendarserver.org/ns/"/>
+ <A:principal-collection-set/>
+ <A:principal-URL/>
+ <A:resource-id/>
+ <A:supported-report-set/>
+ </A:prop>
+</A:propfind>
+```
+
+If the response comes back with `supported-report-set` filled in but no
+`addressbook-home-set`, the client stops. It does not retry at a higher
+`Depth`, and it does not walk the tree looking for the address book. This is
+what [sabre-io/dav#1315][sabre-1315] documents: a well-formed 207 that
+happens to answer at the collection instead of the principal reads to macOS
+as "there is nothing here."
+
+[sabre-1315]: https://github.com/sabre-io/dav/issues/1315
+
+The practical rule is that whatever URL the account points at must answer
+that `PROPFIND` with a usable `addressbook-home-set`.
+
+## `getctag` gates everything after discovery
+
+The client requires the proprietary `{http://calendarserver.org/ns/}getctag`
+property on the address book collection. Without it, the client completes
+discovery and then never requests a single vCard, which presents as an
+account that connects successfully and stays empty.
+
+## One address book per account
+
+Through at least macOS 10.10, Contacts binds one address book per account
+and hides the rest. Later versions are better, but a design that assumes one
+collection per account avoids the question entirely. This suits pro-tacts,
+where a user has one address book anyway.
+
+## vCard 3.0, not 4.0
+
+Apple clients emit and expect vCard 3.0 ([RFC 2426][rfc2426]) with
+`PRODID:-//Apple Inc.//macOS .../EN`. RFC 6352 requires address book
+collections to support 3.0 and makes 4.0 optional, so serving 3.0 is
+compliant, not a compromise.
+
+[rfc2426]: https://datatracker.ietf.org/doc/html/rfc2426
+
+## Smaller traps
+
+- Usernames containing `@` are not percent-encoded by older clients, so
+ email-address usernames break. Avoid them.
+- The `me-card` property (`http://calendarserver.org/ns/`, set on the
+ address book home) is expected; sabre notes crashes when it is missing.
+- Older clients wanted the server at the domain root, and sometimes needed
+ an explicit port. Worth remembering only if something inexplicable shows
+ up on an old machine.
+- One report describes the client probing ports 8443 and 8843 unprompted,
+ and making an unauthenticated request before retrying with credentials.
+ Expect a 401 round trip on every request.
+
+## Reference implementations to compare against
+
+`servers/` runs Baikal, Radicale, and Monica behind mitmproxy, because those
+servers cannot be made to log what we need. Monica is the one confirmed
+working with macOS Contacts here. When pro-tacts and a working server
+disagree, the diff between two recordings of the same client action is the
+fastest way to find out why.
+
+pro-tacts itself is debugged through its own debug logging mode rather than
+through a proxy.
diff --git a/docs/plans/2026-08-12-one-card-on-macos.md b/docs/plans/2026-08-12-one-card-on-macos.md
new file mode 100644
index 0000000..21bd6d6
--- /dev/null
+++ b/docs/plans/2026-08-12-one-card-on-macos.md
@@ -0,0 +1,128 @@
+# Milestone: one card on macOS
+
+Get macOS Contacts to add a pro-tacts account over Tailscale and display a
+single hardcoded contact. Nothing else.
+
+## Why this first
+
+Every later decision about storage, KDL parsing, groups, and sync depends on
+knowing which parts of CardDAV macOS actually exercises. Guessing that from
+the RFCs produces a server that is correct and still invisible to the client
+— the failure mode behind most of the reports in
+[monicahq/monica#4240][monica-4240]. A working end-to-end path turns those
+questions into experiments: change one response, resync, observe.
+
+[monica-4240]: https://github.com/monicahq/monica/issues/4240
+
+It also fixes the network path early. Tailscale serve terminates TLS and
+proxies to the app, which is exactly the arrangement that breaks
+`/.well-known/carddav` redirects for everyone else (see
+`../macos-contacts.md`).
+
+## Capabilities
+
+The server must satisfy, in order:
+
+1. Discovery from a bare hostname: `/.well-known/carddav` leads to a
+ principal that advertises an `addressbook-home-set`.
+2. An address book collection carrying `getctag` and a
+ `supported-report-set`.
+3. A listing of that collection at `Depth: 1` returning one href and one
+ etag.
+4. The card itself, fetched by `GET` and by `addressbook-multiget`.
+
+## Non-goals
+
+Explicitly out of scope, to be built only after this milestone lands:
+
+- Reading contacts from disk. The card is a string constant.
+- KDL parsing and vCard generation.
+- Writes of any kind: `PUT`, `DELETE`, `PROPPATCH`, `MKCOL`.
+- Real etags and ctags. Constants are fine; the client only needs them to be
+ present and stable within a session.
+- `sync-collection`, `addressbook-query` filters, and multiple address books.
+- Multiple users. One principal, one address book, one card.
+- Authentication beyond whatever Tailscale provides.
+- Reducing the responses to the minimal working set. Do the opposite for
+ now: send what working servers send.
+
+## Constraints
+
+- vCard 3.0, with an Apple-shaped `PRODID`. See `../macos-contacts.md`.
+- The `.well-known` redirect must be an absolute `https://` URL. A
+ scheme-relative or `http://` redirect is dropped by the client without
+ reaching the server.
+- Whatever URL the account is configured with must answer the discovery
+ `PROPFIND` at `Depth: 0` with `addressbook-home-set` present. Answering
+ correctly but at the wrong resource ends discovery.
+- `{http://calendarserver.org/ns/}getctag` on the collection is required, or
+ no vCard is ever requested.
+
+## Working loop
+
+Nothing here can be validated from inside the repository. The only oracle is
+Contacts.app on a Mac, and the agent doing the work cannot drive it. So the
+loop is deliberately two-sided.
+
+The agent changes one thing at a time, states what it expects the client to
+do differently, and stops. Batching three response changes into one round
+wastes the expensive half of the loop, because a single "still empty" cannot
+say which change was wrong.
+
+The human resyncs and reports back the raw evidence rather than a verdict:
+the debug log for the exchange, the relevant lines from
+`~/Library/Logs/CardDAVPlugin`, and what Contacts displayed. "It didn't
+work" is not enough to act on; the request the client did or did not make
+next is.
+
+If a round produces no change at all, try removing the account and re-adding
+it before concluding the change was wrong. Several reports of Monica
+suddenly working describe deleting the account first, which suggests the
+client holds onto discovery results — worth confirming for ourselves early,
+since it decides how heavy each round of this loop is.
+
+## Two logging modes
+
+The evidence comes from pro-tacts' own logs, not from a proxy in front of
+it. Reconstructing an exchange without a proxy needs method, path, `Depth`,
+status, and the full request and response bodies — the client states its
+intent in the `PROPFIND` body, so a log line without the body cannot explain
+why the client stopped.
+
+That volume is right for this milestone and wrong for everything after it,
+so make it a setting rather than a decision:
+
+- Normal logging is one line per request, no bodies. The default.
+- Debug logging adds headers and full bodies on both sides. Off by default,
+ switched on by configuration, and clearly the thing you turn on while
+ sitting in the loop above.
+
+Debug logging also conflicts with keeping contact data out of logs, which is
+its own task. Keeping the two modes separate is what lets both be true: the
+debug path can stay verbose because the only card is fictional, and the
+normal path can be narrowed without taking the diagnostics away.
+
+## Verification
+
+Not a test suite — a real client on a real Mac:
+
+1. Add the account in Internet Accounts with Account Type set to Manual and
+ the bare Tailscale hostname as Server Address, Server Path empty.
+2. The account saves without an "unable to verify" error.
+3. Contacts shows the card.
+4. The debug log shows the full discovery sequence and a request for the
+ card, with nothing 404ing along the way.
+
+Keep the debug log of the first fully working exchange. It becomes the
+fixture set for the request-level tests that follow, and the baseline for
+the later task of removing properties one at a time to find the real
+minimum.
+
+## Open questions
+
+- Does Tailscale serve rewrite or drop `PROPFIND` and `REPORT`, or pass
+ unknown methods through untouched?
+- Does the client accept an account with Server Path empty, or does it need
+ the principal URL spelled out? Reports conflict; this milestone answers it
+ for our setup.
+- Does anything break when the same hostname serves both `/` and `/dav/`?