web: route DAV through a hash branch in web/dav.rb
The DAV half moves to web/dav.rb, a hash branch off the trunk,
and the principal replies share one method now; not a fixture byte has shrunk.

Assisted-by: Claude Opus 5 via Claude Code
change qkspuwkrurtssylyumquuzyssnoswuok
commit 3c3223433d766b99df2fd9abc2dffe674ca3567c
author Alpha Chen <alpha@kejadlen.dev>
date
parent zxpslruk
diff --git a/AGENTS.md b/AGENTS.md
index e35a3da..36992e4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -35,7 +35,10 @@ is already `pro-tacts` — don't pass `--backlog` or verify it.
 
 ```
 lib/pro_tacts/
-├── web.rb          # The Roda app: every route and response body
+├── web.rb          # The Roda app: the identity gate, the root, and
+│                   # the admin routes
+├── web/dav.rb      # Discovery and the address book: every DAV route
+│                   # and response body
 ├── store.rb        # Sequel over SQLite: cards, change log, derived index
 ├── contact.rb      # the one model of a contact: id, vCard, etag,
 │                   # and the structured accessors over the card
@@ -77,7 +80,7 @@ not a fixture; edit a `.vcf` to change what the replay serves.
 
 ## Gotchas
 
-- Response bodies are built with heredocs in `web.rb`. A comment written
+- Response bodies are built with heredocs in `web/dav.rb`. A comment written
   inside one is sent to the client — keep notes about the code in Ruby
   comments outside the heredoc.
 - Every request needs `Tailscale-User-Login` and `Tailscale-User-Name`
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index b11c4b5..52cb2c4 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -1,5 +1,4 @@
 
-require "digest"
 require "pathname"
 require "securerandom"
 
@@ -7,7 +6,6 @@ require "pro_tacts"
 require "sentry-ruby"
 
 require "rack/rewindable_input"
-require "nokogiri"
 require "roda"
 
 require "pro_tacts/admin/card_form"
@@ -83,22 +81,24 @@ module ProTacts
     plugin :all_verbs
     plugin :dav_verbs
     plugin :public, root: PUBLIC_ROOT.to_s
+    plugin :hash_branches
 
     plugin :not_found do
       Sentry.capture_message("404 Not Found", level: :warning)
       "Not Found"
     end
 
-    # Four specs meet in this router: WebDAV itself (RFC 4918), the
-    # CardDAV profile on top of it (RFC 6352), collection sync (RFC 6578),
-    # and service discovery (RFC 6764). Each handler cites its section, and
-    # the texts are vendored under docs/rfcs to check them against.
+    # The router's trunk: the identity gate, the static files, the admin
+    # screens, and the root the admin and DAV halves share. The DAV
+    # routes are hash_branches in lib/pro_tacts/web/dav.rb, run on this
+    # same instance, so what the trunk sets they see.
     route do |r|
       # Every request names a tailnet user or is refused, the static
       # files included (ProTacts::TailscaleAuth).
       @identity = TailscaleAuth.identity(r.env) || unauthorized(r)
 
       r.public
+      r.hash_branches
 
       # The card browser, one segment down from the page at the root:
       # /contacts/:id names what the id is without spending the whole
@@ -280,367 +280,14 @@ module ProTacts
           dashboard(query: r.params["q"])
         end
 
-        # DAV:current-user-principal (RFC 5397 section 3) — what a client
-        # asks the root for to find the principal it is acting as.
         r.propfind do
-          response["Content-Type"] = "text/xml"
-          response.status = 207
-
-          <<~XML
-            <?xml version="1.0" encoding="UTF-8"?>
-            <d:multistatus xmlns:d="DAV:">
-              <d:response>
-                <d:href>/</d:href>
-                <d:propstat>
-                  <d:prop>
-                    <d:current-user-principal>
-                      <d:href>/dav/principal/</d:href>
-                    </d:current-user-principal>
-                  </d:prop>
-                  <d:status>HTTP/1.1 200 OK</d:status>
-                </d:propstat>
-              </d:response>
-            </d:multistatus>
-          XML
-        end
-      end
-
-      # The "carddav" well-known URI (RFC 6764 section 5, registered in
-      # section 9.1.2), the start of a client's bootstrap.
-      r.on ".well-known/carddav" do
-        r.propfind do
-          response["Content-Type"] = "text/xml"
-          response.status = 207
-
-          <<~XML
-            <?xml version="1.0" encoding="UTF-8"?>
-            <d:multistatus xmlns:d="DAV:">
-              <d:response>
-                <d:href>/.well-known/carddav</d:href>
-                <d:propstat>
-                  <d:prop>
-                    <d:current-user-principal>
-                      <d:href>/dav/principal/</d:href>
-                    </d:current-user-principal>
-                  </d:prop>
-                  <d:status>HTTP/1.1 200 OK</d:status>
-                </d:propstat>
-              </d:response>
-            </d:multistatus>
-          XML
-        end
-
-        # RFC 6764 section 5 forbids putting the service itself here and
-        # requires a redirect to the real context path; 301 is one of the
-        # codes it names.
-        r.get do
-          r.redirect "/dav/principal/", 301
-        end
-      end
-
-      r.on "dav" do
-        # "addressbook" in the DAV header is how a client detects CardDAV
-        # support (RFC 6352 section 6.1); the header is RFC 4918 section 10.1.
-        r.options do
-          response["DAV"] = "addressbook"
-          response["Allow"] = "OPTIONS, PROPFIND, REPORT, PUT, DELETE"
-          ""
-        end
-
-        # CARDDAV:addressbook-home-set (RFC 6352 section 7.1.1) is the
-        # hop from principal to collection.
-        r.on "principal" do
-          r.propfind do
-            response["Content-Type"] = "text/xml"
-            response.status = 207
-
-            <<~XML
-              <?xml version="1.0" encoding="UTF-8"?>
-              <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
-                <d:response>
-                  <d:href>/dav/principal/</d:href>
-                  <d:propstat>
-                    <d:prop>
-                      <card:addressbook-home-set>
-                        <d:href>/dav/addressbook/</d:href>
-                      </card:addressbook-home-set>
-                    </d:prop>
-                    <d:status>HTTP/1.1 200 OK</d:status>
-                  </d:propstat>
-                </d:response>
-              </d:multistatus>
-            XML
-          end
-        end
-
-        r.on "addressbook" do
-          r.propfind do
-            body = request.body.read
-            request.body.rewind
-
-            response["Content-Type"] = "text/xml"
-            response.status = 207
-
-            # PROPFIND is RFC 4918 section 9.1, its Depth header section
-            # 10.2, and the 207 body it returns section 13.
-            depth = request.env.fetch("HTTP_DEPTH", "infinity")
-
-            etag_only = body.include?("getetag") && !body.include?("displayname") && !body.include?("resourcetype")
-
-            # The collection self-entry is omitted from an etag-only ask
-            # until a client is found to need it.
-            collection_response = ""
-            unless etag_only
-              # Every property in this body and where it comes from:
-              # DAV:resourcetype, which an address book collection MUST report
-              # as both collection and addressbook (RFC 6352 section 5.2);
-              # DAV:supported-report-set (RFC 3253 section 3.1.5), which RFC
-              # 6578 section 3.2 requires list sync-collection;
-              # DAV:sync-token (RFC 6578 section 4); and
-              # DAV:current-user-privilege-set (RFC 3744 section 5.4), which
-              # RFC 6352 section 7 requires of a CardDAV server. getctag alone
-              # is not standardized — an Apple CalendarServer extension in the
-              # calendarserver.org namespace, kept because macOS polls it.
-              #
-              # The privileges are advertised ahead of the methods
-              # granting some of them. macOS Contacts asks for this
-              # property on every poll and attempts no write without it,
-              # so claiming them is what makes the client send writes at
-              # all — the PUT they prompted was what log/unhandled
-              # captured them for. DAV:write covers PUT and PROPPATCH
-              # (RFC 3744 section 3.2), and PUT is the one of the pair
-              # answered; DAV:unbind is removing a member from the
-              # collection (section 3.10), which DELETE answers.
-              # DAV:bind is adding one (section 3.9), and the create it
-              # names — POST to the collection with DAV:add-member — has
-              # no route: PUT to the member URI is how both clients
-              # create, so nothing has asked for it.
-              collection_response = <<~XML
-                <d:response>
-                  <d:href>/dav/addressbook/</d:href>
-                  <d:propstat>
-                    <d:prop>
-                      <d:resourcetype>
-                        <d:collection/>
-                        <card:addressbook/>
-                      </d:resourcetype>
-                      <d:supported-report-set>
-                        <d:supported-report>
-                          <d:report><d:sync-collection/></d:report>
-                        </d:supported-report>
-                      </d:supported-report-set>
-                      <cs:getctag>#{ctag}</cs:getctag>
-                      <d:sync-token>#{sync_token}</d:sync-token>
-                      <d:current-user-privilege-set>
-                        <d:privilege><d:read/></d:privilege>
-                        <d:privilege><d:write/></d:privilege>
-                        <d:privilege><d:bind/></d:privilege>
-                        <d:privilege><d:unbind/></d:privilege>
-                      </d:current-user-privilege-set>
-                    </d:prop>
-                    <d:status>HTTP/1.1 200 OK</d:status>
-                  </d:propstat>
-                </d:response>
-              XML
-            end
-
-            members = depth == "0" ? "" : contacts.map { etag_response(it) }.join
-
-            <<~XML
-              <?xml version="1.0" encoding="UTF-8"?>
-              <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
-                #{collection_response}
-                #{members}
-              </d:multistatus>
-            XML
-          end
-
-          r.report do
-            body = request.body.read
-            request.body.rewind
-
-            response["Content-Type"] = "text/xml"
-            response.status = 207
-
-            doc = Nokogiri::XML(body)
-            doc.remove_namespaces!
-
-            # A body that is not XML parses to a document with no root.
-            # There is no report to dispatch on, so raise and let it 500
-            # rather than answer as though nothing was asked for.
-            root = doc.root
-            raise ArgumentError, "REPORT body is not XML" if root.nil?
-
-            # Each branch renders the whole response body: a Roda route
-            # block cannot return early, so the unsupported case has to be
-            # a value like the others rather than a return.
-            case root.name
-            when "sync-collection"
-              # DAV:sync-collection (RFC 6578 section 3.2): the members
-              # added, changed, or removed since the token the client
-              # holds, and the token naming the state the answer reaches.
-              # The warm-sync ask is etag-only; a changed etag sends the
-              # client back through multiget, so no address-data here.
-              #
-              # The token carries the change log's sequence and whose book
-              # it was issued for (see #sync_token), and the delta is the
-              # log after the sequence, answered from the requester's book
-              # as it is now: a card that left it answers as removed
-              # (section 3.5.2), and so does one that never was in it,
-              # which the log cannot tell apart. One response per
-              # member, the net of everything it did in the
-              # window — a member put twice since the token answers once,
-              # at its current etag, the multiple-changes case section 3.5
-              # allows one response for. A member whose net is a removal
-              # answers as section 3.2 spells it: href and 404, no
-              # propstat.
-              #
-              # Depth stays unread though the section defines the report
-              # only at 0: macOS sends Depth: 1 (fixture 08), and 400-ing
-              # the one real client over a header it ignores serves the
-              # letter over the exchange. sync-level likewise — 1 and
-              # infinite are the same answer here, the collection has no
-              # member collections (section 3.3).
-              token = doc.xpath("//sync-token").first&.text.to_s.strip
-              if token.empty?
-                # No token is the initial sync: every member, changed
-                # (section 3.4).
-                multistatus(contacts.map { etag_response(it) }, sync_token:)
-              elsif (sequence = token[%r{\Ahttp://pro-tacts/sync/(\d+)/#{book_digest}\z}, 1]) &&
-                  sequence.to_i <= store.latest_sequence
-                net = store.changes(after: sequence.to_i).map { it.card_id }.uniq
-                responses = net.map { |id|
-                  contact = contacts.find { it.id == id }
-                  contact ? etag_response(contact) : missing_response(contact_href(id))
-                }
-                multistatus(responses, sync_token:)
-              else
-                # A token this server never issued, one issued for another
-                # book, or one naming a state past the present: the
-                # section's DAV:valid-sync-token
-                # precondition, marshalled per RFC 4918 section 16. 410
-                # rather than 403 because its fallback is a full resync —
-                # the request will not always fail, and the state the
-                # token names is gone.
-                response.status = 410
-
-                <<~XML
-                  <?xml version="1.0" encoding="UTF-8"?>
-                  <d:error xmlns:d="DAV:">
-                    <d:valid-sync-token/>
-                  </d:error>
-                XML
-              end
-            when "addressbook-multiget"
-              # CARDDAV:addressbook-multiget (RFC 6352 section 8.7); the
-              # address-data the client asks for is section 10.4.
-              wants_cards = doc.xpath("//address-data").any?
-
-              multistatus(doc.xpath("//href").map { it.text }.map { |requested|
-                id = requested[%r{\A/dav/addressbook/([^/]+)\.vcf\z}, 1]
-                contact = id && contacts.find { it.id == id }
-
-                if contact
-                  wants_cards ? card_response(contact) : etag_response(contact)
-                else
-                  missing_response(requested)
-                end
-              })
-            else
-              # The DAV:supported-report precondition on REPORT (RFC 3253
-              # section 3.6) — the report asked for has to be one the
-              # resource supports. Answering an unsupported report with an
-              # empty 207 reads to the client as a successful empty result,
-              # and to us as nothing at all: 207 is not a status
-              # UnhandledRequests captures, so the one signal that a client
-              # wanted something unimplemented never fired.
-              #
-              # 403 with the precondition named in a DAV:error body is the
-              # marshalling RFC 4918 section 16 defines, and 403 is its
-              # "will always fail, do not repeat" case. addressbook-query
-              # (RFC 6352 section 8.6) is the report this rejects today;
-              # macOS Contacts has never sent one.
-              response.status = 403
-
-              <<~XML
-                <?xml version="1.0" encoding="UTF-8"?>
-                <d:error xmlns:d="DAV:">
-                  <d:supported-report/>
-                </d:error>
-              XML
-            end
-          end
-
-          # Read on its own rather than through the collection: serving
-          # one href has no reason to load every other contact first.
-          r.get String do |filename|
-            contact = member(filename.delete_suffix(".vcf"))
-
-            if contact
-              response["Content-Type"] = "text/vcard; charset=utf-8"
-              # RFC 7232 section 2.3; must match the getetag reported for
-              # this contact in PROPFIND and REPORT.
-              response["ETag"] = contact.etag
-              contact.vcard.to_s
-            end
-          end
-
-          # PUT to an unmapped URI creates the card there and PUT to a
-          # mapped one replaces it (RFC 6352 section 6.3.2). macOS
-          # creates with If-None-Match: * and a UUID it mints into both
-          # the URI and the card's UID, and updates with If-Match
-          # carrying the strong etag it was last served — see
-          # docs/apple-contacts.md, "What a write looks like on the wire".
-          r.put String do |filename|
-            id = filename.delete_suffix(".vcf")
-            # A last segment that is not this server's <id>.vcf shape
-            # cannot address a resource here, created or read — the
-            # same fall-through-to-404 the GET handler gives it.
-            write_card(id) if id.match?(Contact::ID_FORMAT)
-          end
-
-          # DELETE removes the card at the member URI (RFC 4918 section
-          # 9.6) — the DAV:unbind privilege the collection advertises,
-          # and what iOS sends when a contact is deleted on the phone.
-          r.delete String do |filename|
-            id = filename.delete_suffix(".vcf")
-            remove_card(id) if id.match?(Contact::ID_FORMAT)
-          end
+          current_user_principal("/")
         end
       end
     end
 
     private
 
-    # Read once per request — Roda builds a fresh app instance for each
-    # one — from the store the whole process shares. Reading a family
-    # address book per request is cheap and can never serve a stale one.
-    #
-    # The requester's book and nothing else: every route under /dav serves
-    # that collection (docs/plans/2026-09-12-per-user-books.md).
-    #: () -> Array[Contact]
-    def contacts
-      @contacts ||= store.contacts.select { book.include?(it.id) }
-    end
-
-    #: () -> Set[String]
-    def book
-      @book ||= store.book(@identity.name)
-    end
-
-    # One member of the requester's book, or nil for a card outside it
-    # as for one that does not exist: neither is a member of this
-    # collection.
-    #: (String id) -> Contact?
-    def member(id)
-      store.contact(id) if book.include?(id)
-    end
-
-    #: () -> String
-    def ctag
-      @ctag ||= store.ctag
-    end
-
     # The dashboard GET and a refused create render the same page,
     # which is why the query travels both paths — a create refused
     # under a search re-renders the results it was refused over.
@@ -669,154 +316,11 @@ module ProTacts
       r.halt
     end
 
-    # Sync tokens are opaque to the client (RFC 6578 section 3); the URI
-    # form is conventional. Built on the ctag so a client polling either
-    # one sees changes at the same points, and on the requester's name so
-    # that a token is refused by any other book, the one a rename leaves
-    # a user with included (docs/plans/2026-09-12-per-user-books.md, "The
-    # wire").
-    #: () -> String
-    def sync_token
-      "http://pro-tacts/sync/#{ctag}/#{book_digest}"
-    end
-
-    # The name half of a sync token: the first 16 hex digits of the
-    # SHA-256 of the requester's display name.
-    #: () -> String
-    def book_digest
-      Digest::SHA256.hexdigest(@identity.name)[0, 16].to_s
-    end
-
     #: () -> Store
     def store
       self.class.store
     end
 
-    # The whole of the PUT route: a private method because a Roda route
-    # block cannot return early, so each refusal is a value the block
-    # ends with rather than a branch it exits. The checks run in the
-    # order RFC 7232 section 5 sets for a request with preconditions —
-    # the request's own validity first, the conditionals on stored
-    # state after — so a card that cannot be stored hears
-    # CARDDAV:valid-address-data even when its If-Match is stale too.
-    #: (String id) -> String?
-    def write_card(id)
-      # A card outside the requester's book is no member of this
-      # collection (#member), and its id cannot be created here either,
-      # being taken: the 404 the GET of it gets.
-      return if !book.include?(id) && store.contact(id)
-
-      # CARDDAV:supported-address-data (RFC 6352 section 6.3.2.1): what
-      # arrived must be a vCard, and text/vcard is the one media type
-      # this server stores. Asked before the body is read because it is
-      # a question about the request, not about what the request
-      # carried.
-      return precondition("supported-address-data") unless request.media_type == "text/vcard"
-
-      # Decoding the body, both halves of it: Rack requires input in
-      # ASCII-8BIT and Rack::RewindableInput enforces it again, so the
-      # bytes are relabelled rather than converted — untouched — and the
-      # label is checked honest in the same breath, because a
-      # force_encoding nobody validates is a lie every later reader
-      # inherits. That the bytes are not the UTF-8 the media type
-      # promised is a fact about this body, not about any card, which is
-      # why it is settled here and VCard is never handed the question
-      # (CARDDAV:valid-address-data, RFC 6352 section 6.3.2.1). Left
-      # unsettled it would surface as a 500 out of SQLite on the insert,
-      # past the point where the client could be told what was wrong.
-      # Paths need no counterpart: Puma hands PATH_INFO over still
-      # percent-encoded, so an id off the wire is ASCII.
-      bytes = request.body.read.force_encoding(Encoding::UTF_8)
-      return precondition("valid-address-data") unless bytes.valid_encoding?
-
-      # CARDDAV:valid-address-data again, on the card this time: the
-      # envelope RFC 2426 section 4 requires. That is the whole test. A
-      # line inside it this parser cannot read is not grounds for
-      # refusing the card: RFC 6352 section 6.3.2.2 has the server keep
-      # what it does not understand, and it is kept — the stored bytes
-      # are what goes back out.
-      vcard = VCard.new(bytes)
-      return precondition("valid-address-data") unless vcard.card?
-
-      # CARDDAV:no-uid-conflict: the submitted UID must not belong to a
-      # different resource, and a mapped URI must not be overwritten by
-      # a card carrying a different UID. Here the id is the card's UID
-      # (see Contact), so both clauses come down to: the card carries a
-      # UID naming the resource being written, and no other card claims
-      # it. The href in the body is the SHOULD that section attaches to
-      # the first clause — report where the UID already lives.
-      uid = vcard.uid
-      owner = uid && store.card_id_with_uid(uid)
-      return precondition("no-uid-conflict", owner) if uid != id || owner && owner != id
-
-      existing = store.contact(id)
-
-      # The lost-update conditionals, RFC 7232 sections 3.1 and 3.2.
-      # macOS sends If-Match on updates and If-None-Match: * on creates;
-      # a PUT carrying neither is unconditional and allowed to proceed.
-      if_match = request.env["HTTP_IF_MATCH"]
-      return plain_412 if if_match && !if_match_satisfied?(if_match, existing)
-
-      if_none_match = request.env["HTTP_IF_NONE_MATCH"]
-      return plain_412 if if_none_match && if_none_match_failed?(if_none_match, existing)
-
-      report_unreadable_lines(vcard)
-      report_broken_assumptions(vcard)
-
-      stored = store.put(id, vcard, sync_to: @identity.name)
-      response.status = existing ? 204 : 201
-      # A strong ETag belongs on the answer only when what the resource
-      # now serves is the submitted bytes, octet for octet — the one
-      # case RFC 6352 section 6.3.2.3 lets a client rely on the tag it
-      # gets back, and anywhere else it forbids one outright. Which is
-      # the composed card against the body, not the stored one: a
-      # birthday and a group's lines are both subtracted before storage
-      # and composed back in on read, so a member returning the card it
-      # downloaded stores fewer bytes than it sent and still gets the
-      # tag, while a PUT that carried a birthday somewhere other than
-      # where compose puts it back serves bytes that are not the ones
-      # it sent, and the client refetches.
-      response["ETag"] = stored.etag if stored.vcard.to_s == vcard.to_s
-
-      # A returned "" would land in the body and pin text/html and
-      # content-length onto the 204, which a bodyless status must not
-      # carry (Rack 3's lint rejects both); nil leaves it bodyless.
-      nil
-    end
-
-    # The whole of the DELETE, a private method for write_card's reason:
-    # a Roda route block cannot return early.
-    #: (String id) -> String?
-    def remove_card(id)
-      # An unmapped URI is a 404, not a silent success: RFC 4918 section
-      # 9.6 gives DELETE no idempotent status, and a client deleting what
-      # it believes exists is owed the disagreement. Falls through to the
-      # not_found handler the way the GET and PUT of a bad id do. A card
-      # outside the requester's book is unmapped here too (#member).
-      existing = member(id)
-      return unless existing
-
-      # The lost-update conditional, RFC 7232 section 3.1, on the same
-      # terms the PUT gets it: a client that names the representation it
-      # means to remove must not remove one that changed underneath it.
-      # No recorded session has sent one on a DELETE — the iOS delete
-      # carried none, which section 3.1 leaves unconditional and
-      # allowed, and macOS has sent no DELETE at all. Answered anyway,
-      # because ignoring the header is the lost update it exists to
-      # refuse, and the PUT's own check is right here to reuse.
-      if_match = request.env["HTTP_IF_MATCH"]
-      return plain_412 if if_match && !if_match_satisfied?(if_match, existing)
-
-      # The change-log entry Store#delete leaves in the same transaction
-      # is what a syncing client is told: sync-collection answers a
-      # removed member as href plus 404 (#missing_response).
-      store.delete(id)
-      response.status = 204
-
-      # Bodyless for the 204, write_card's reason.
-      nil
-    end
-
     # The whole of the edit POST, a private method for the same reason
     # write_card is one: a Roda route block cannot return early, so each
     # refusal is a value the block ends with rather than a branch it
@@ -924,170 +428,8 @@ module ProTacts
       response["Content-Type"] = "text/html; charset=utf-8"
       Admin::ContactsEdit.call(contact:, notice:)
     end
-
-    # A card that breaks one of the parser's assumptions is not bad
-    # input, it is news that the assumption is wrong and every
-    # simplification resting on it is now suspect — an error, where a
-    # line that merely would not read is ordinary bad input and warns
-    # instead (report_unreadable_lines, below). The card is accepted,
-    # stored, and served either way, and a line that will not read
-    # costs it only its index rows.
-    #
-    # Here rather than in the parser, which is where the break is
-    # found: a read happens on every look at a card — the admin index
-    # reads every contact on every page load — and one bad card must
-    # not alert once per page view. An arrival is the event worth a
-    # message, and a PUT is the only arrival there is. The message
-    # carries no card content (ProTacts::SentryScrubber's line); the
-    # admin view shows the card raw.
-    #: (VCard vcard) -> void
-    def report_broken_assumptions(vcard)
-      broken = vcard.lines.count { it.broke_assumption? }
-      return if broken.zero?
-
-      Sentry.capture_message(
-        "a submitted card broke #{broken} parser assumption(s) about what macOS Contacts sends",
-        level: :error,
-      )
-    end
-
-    # The ordinary half of the arrival reports: a line that would not
-    # read is bad input rather than news, so it warns — the level and
-    # the no-card-content line the store's BDAY reports already use.
-    # BrokenAssumption lines are excluded, so a line reports once; why
-    # this lives at the PUT is the comment above, and holds for both.
-    #: (VCard vcard) -> void
-    def report_unreadable_lines(vcard)
-      unreadable = vcard.lines.count { it.unreadable? }
-      return if unreadable.zero?
-
-      Sentry.capture_message(
-        "a submitted card carried #{unreadable} line(s) that would not read — stored verbatim, missing from the index",
-        level: :warning,
-      )
-    end
-
-    # A 412 whose body names the CardDAV precondition that failed, in
-    # the DAV:error form RFC 4918 section 16 defines; the element names
-    # are RFC 6352 section 6.3.2.1's, qualified by the card: namespace
-    # the body declares. no-uid-conflict carries the conflicting card's
-    # href when there is one.
-    #: (String element, ?String? conflict_id) -> String
-    def precondition(element, conflict_id = nil)
-      named = if conflict_id
-        "<card:#{element}><d:href>#{contact_href(conflict_id)}</d:href></card:#{element}>"
-      else
-        "<card:#{element}/>"
-      end
-      response.status = 412
-
-      <<~XML
-        <?xml version="1.0" encoding="UTF-8"?>
-        <d:error xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
-          #{named}
-        </d:error>
-      XML
-    end
-
-    # A bare 412: these failures are HTTP's own conditionals (RFC 7232),
-    # not a WebDAV precondition with an element to name in a body.
-    #: () -> String
-    def plain_412
-      response.status = 412
-      ""
-    end
-
-    # RFC 7232 section 3.1: If-Match passes when the current etag is one
-    # of those listed, or, for `*`, when there is a current
-    # representation at all.
-    #: (String header, Contact? existing) -> bool
-    def if_match_satisfied?(header, existing)
-      return !existing.nil? if header.strip == "*"
-
-      !existing.nil? && header.split(",").map(&:strip).include?(existing.etag)
-    end
-
-    # RFC 7232 section 3.2: If-None-Match fails a non-GET request when
-    # the current etag is one of those listed, or, for `*`, whenever the
-    # resource exists. The create — If-None-Match: * against an unmapped
-    # URI — is the case CardDAV clients send (RFC 6352 section 6.3.2).
-    #: (String header, Contact? existing) -> bool
-    def if_none_match_failed?(header, existing)
-      return !existing.nil? if header.strip == "*"
-
-      !existing.nil? && header.split(",").map(&:strip).include?(existing.etag)
-    end
-
-    # A 207 body. The trailing DAV:sync-token is RFC 6578 section 3.2's
-    # — a sync-collection response MUST carry one naming the state the
-    # answer reaches; no other multistatus here has a state to name.
-    #: (Array[String] responses, ?sync_token: String) -> String
-    def multistatus(responses, sync_token: nil)
-      trailing = sync_token ? "  <d:sync-token>#{sync_token}</d:sync-token>\n" : ""
-      <<~XML
-        <?xml version="1.0" encoding="UTF-8"?>
-        <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
-          #{responses.join}#{trailing}
-        </d:multistatus>
-      XML
-    end
-
-    #: (String id) -> String
-    def contact_href(id)
-      "/dav/addressbook/#{id}.vcf"
-    end
-
-    # DAV:getetag (RFC 4918 section 15.6).
-    #: (Contact contact) -> String
-    def etag_response(contact)
-      <<~XML
-        <d:response>
-          <d:href>#{contact_href(contact.id)}</d:href>
-          <d:propstat>
-            <d:prop>
-              <d:getetag>#{contact.etag}</d:getetag>
-            </d:prop>
-            <d:status>HTTP/1.1 200 OK</d:status>
-          </d:propstat>
-        </d:response>
-      XML
-    end
-
-    #: (Contact contact) -> String
-    def card_response(contact)
-      <<~XML
-        <d:response>
-          <d:href>#{contact_href(contact.id)}</d:href>
-          <d:propstat>
-            <d:prop>
-              <d:getetag>#{contact.etag}</d:getetag>
-              <card:address-data>#{xml_escape(contact.vcard.to_s.chomp)}</card:address-data>
-            </d:prop>
-            <d:status>HTTP/1.1 200 OK</d:status>
-          </d:propstat>
-        </d:response>
-      XML
-    end
-
-    # A member the collection does not answer for: the multiget miss,
-    # reported as a 404 inside the 207 rather than failing the request
-    # (RFC 6352 section 8.7), and the removal shape a sync-collection
-    # delta reports (RFC 6578 section 3.2 — href and 404, no propstat).
-    #: (String requested) -> String
-    def missing_response(requested)
-      <<~XML
-        <d:response>
-          <d:href>#{xml_escape(requested)}</d:href>
-          <d:status>HTTP/1.1 404 Not Found</d:status>
-        </d:response>
-      XML
-    end
-
-    # Text nodes in XML built by interpolation; hrefs and vCard content
-    # can all contain &, <, or >.
-    #: (String text) -> String
-    def xml_escape(text)
-      text.gsub(/[&<>]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;")
-    end
   end
 end
+
+# The branches reopen Web, so they load once the plugins they call are in.
+require "pro_tacts/web/dav"
diff --git a/lib/pro_tacts/web/dav.rb b/lib/pro_tacts/web/dav.rb
new file mode 100644
index 0000000..a03628c
--- /dev/null
+++ b/lib/pro_tacts/web/dav.rb
@@ -0,0 +1,665 @@
+require "digest"
+require "nokogiri"
+
+module ProTacts
+  # The CardDAV half of the router: service discovery and the one
+  # address book each user syncs (docs/plans/2026-09-12-per-user-books.md).
+  # Four specs meet here: WebDAV itself (RFC 4918), the CardDAV profile
+  # on top of it (RFC 6352), collection sync (RFC 6578), and service
+  # discovery (RFC 6764). Each handler cites its section, and the texts
+  # are vendored under docs/rfcs to check them against.
+  class Web < Roda
+    # The "carddav" well-known URI (RFC 6764 section 5, registered in
+    # section 9.1.2), the start of a client's bootstrap.
+    hash_branch(".well-known") do |r|
+      r.on "carddav" do
+        r.propfind do
+          current_user_principal("/.well-known/carddav")
+        end
+
+        # RFC 6764 section 5 forbids putting the service itself here and
+        # requires a redirect to the real context path; 301 is one of the
+        # codes it names.
+        r.get do
+          r.redirect "/dav/principal/", 301
+        end
+      end
+    end
+
+    hash_branch("dav") do |r|
+      # "addressbook" in the DAV header is how a client detects CardDAV
+      # support (RFC 6352 section 6.1); the header is RFC 4918 section 10.1.
+      r.options do
+        response["DAV"] = "addressbook"
+        response["Allow"] = "OPTIONS, PROPFIND, REPORT, PUT, DELETE"
+        ""
+      end
+
+      # CARDDAV:addressbook-home-set (RFC 6352 section 7.1.1) is the
+      # hop from principal to collection.
+      r.on "principal" do
+        r.propfind do
+          response["Content-Type"] = "text/xml"
+          response.status = 207
+
+          <<~XML
+            <?xml version="1.0" encoding="UTF-8"?>
+            <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+              <d:response>
+                <d:href>/dav/principal/</d:href>
+                <d:propstat>
+                  <d:prop>
+                    <card:addressbook-home-set>
+                      <d:href>/dav/addressbook/</d:href>
+                    </card:addressbook-home-set>
+                  </d:prop>
+                  <d:status>HTTP/1.1 200 OK</d:status>
+                </d:propstat>
+              </d:response>
+            </d:multistatus>
+          XML
+        end
+      end
+
+      r.on "addressbook" do
+        r.propfind do
+          body = request.body.read
+          request.body.rewind
+
+          response["Content-Type"] = "text/xml"
+          response.status = 207
+
+          # PROPFIND is RFC 4918 section 9.1, its Depth header section
+          # 10.2, and the 207 body it returns section 13.
+          depth = request.env.fetch("HTTP_DEPTH", "infinity")
+
+          etag_only = body.include?("getetag") && !body.include?("displayname") && !body.include?("resourcetype")
+
+          # The collection self-entry is omitted from an etag-only ask
+          # until a client is found to need it.
+          collection_response = ""
+          unless etag_only
+            # Every property in this body and where it comes from:
+            # DAV:resourcetype, which an address book collection MUST report
+            # as both collection and addressbook (RFC 6352 section 5.2);
+            # DAV:supported-report-set (RFC 3253 section 3.1.5), which RFC
+            # 6578 section 3.2 requires list sync-collection;
+            # DAV:sync-token (RFC 6578 section 4); and
+            # DAV:current-user-privilege-set (RFC 3744 section 5.4), which
+            # RFC 6352 section 7 requires of a CardDAV server. getctag alone
+            # is not standardized — an Apple CalendarServer extension in the
+            # calendarserver.org namespace, kept because macOS polls it.
+            #
+            # The privileges are advertised ahead of the methods
+            # granting some of them. macOS Contacts asks for this
+            # property on every poll and attempts no write without it,
+            # so claiming them is what makes the client send writes at
+            # all — the PUT they prompted was what log/unhandled
+            # captured them for. DAV:write covers PUT and PROPPATCH
+            # (RFC 3744 section 3.2), and PUT is the one of the pair
+            # answered; DAV:unbind is removing a member from the
+            # collection (section 3.10), which DELETE answers.
+            # DAV:bind is adding one (section 3.9), and the create it
+            # names — POST to the collection with DAV:add-member — has
+            # no route: PUT to the member URI is how both clients
+            # create, so nothing has asked for it.
+            collection_response = <<~XML
+              <d:response>
+                <d:href>/dav/addressbook/</d:href>
+                <d:propstat>
+                  <d:prop>
+                    <d:resourcetype>
+                      <d:collection/>
+                      <card:addressbook/>
+                    </d:resourcetype>
+                    <d:supported-report-set>
+                      <d:supported-report>
+                        <d:report><d:sync-collection/></d:report>
+                      </d:supported-report>
+                    </d:supported-report-set>
+                    <cs:getctag>#{ctag}</cs:getctag>
+                    <d:sync-token>#{sync_token}</d:sync-token>
+                    <d:current-user-privilege-set>
+                      <d:privilege><d:read/></d:privilege>
+                      <d:privilege><d:write/></d:privilege>
+                      <d:privilege><d:bind/></d:privilege>
+                      <d:privilege><d:unbind/></d:privilege>
+                    </d:current-user-privilege-set>
+                  </d:prop>
+                  <d:status>HTTP/1.1 200 OK</d:status>
+                </d:propstat>
+              </d:response>
+            XML
+          end
+
+          members = depth == "0" ? "" : contacts.map { etag_response(it) }.join
+
+          <<~XML
+            <?xml version="1.0" encoding="UTF-8"?>
+            <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav" xmlns:cs="http://calendarserver.org/ns/">
+              #{collection_response}
+              #{members}
+            </d:multistatus>
+          XML
+        end
+
+        r.report do
+          body = request.body.read
+          request.body.rewind
+
+          response["Content-Type"] = "text/xml"
+          response.status = 207
+
+          doc = Nokogiri::XML(body)
+          doc.remove_namespaces!
+
+          # A body that is not XML parses to a document with no root.
+          # There is no report to dispatch on, so raise and let it 500
+          # rather than answer as though nothing was asked for.
+          root = doc.root
+          raise ArgumentError, "REPORT body is not XML" if root.nil?
+
+          # Each branch renders the whole response body: a Roda route
+          # block cannot return early, so the unsupported case has to be
+          # a value like the others rather than a return.
+          case root.name
+          when "sync-collection"
+            # DAV:sync-collection (RFC 6578 section 3.2): the members
+            # added, changed, or removed since the token the client
+            # holds, and the token naming the state the answer reaches.
+            # The warm-sync ask is etag-only; a changed etag sends the
+            # client back through multiget, so no address-data here.
+            #
+            # The token carries the change log's sequence and whose book
+            # it was issued for (see #sync_token), and the delta is the
+            # log after the sequence, answered from the requester's book
+            # as it is now: a card that left it answers as removed
+            # (section 3.5.2), and so does one that never was in it,
+            # which the log cannot tell apart. One response per
+            # member, the net of everything it did in the
+            # window — a member put twice since the token answers once,
+            # at its current etag, the multiple-changes case section 3.5
+            # allows one response for. A member whose net is a removal
+            # answers as section 3.2 spells it: href and 404, no
+            # propstat.
+            #
+            # Depth stays unread though the section defines the report
+            # only at 0: macOS sends Depth: 1 (fixture 08), and 400-ing
+            # the one real client over a header it ignores serves the
+            # letter over the exchange. sync-level likewise — 1 and
+            # infinite are the same answer here, the collection has no
+            # member collections (section 3.3).
+            token = doc.xpath("//sync-token").first&.text.to_s.strip
+            if token.empty?
+              # No token is the initial sync: every member, changed
+              # (section 3.4).
+              multistatus(contacts.map { etag_response(it) }, sync_token:)
+            elsif (sequence = token[%r{\Ahttp://pro-tacts/sync/(\d+)/#{book_digest}\z}, 1]) &&
+                sequence.to_i <= store.latest_sequence
+              net = store.changes(after: sequence.to_i).map { it.card_id }.uniq
+              responses = net.map { |id|
+                contact = contacts.find { it.id == id }
+                contact ? etag_response(contact) : missing_response(contact_href(id))
+              }
+              multistatus(responses, sync_token:)
+            else
+              # A token this server never issued, one issued for another
+              # book, or one naming a state past the present: the
+              # section's DAV:valid-sync-token
+              # precondition, marshalled per RFC 4918 section 16. 410
+              # rather than 403 because its fallback is a full resync —
+              # the request will not always fail, and the state the
+              # token names is gone.
+              response.status = 410
+
+              <<~XML
+                <?xml version="1.0" encoding="UTF-8"?>
+                <d:error xmlns:d="DAV:">
+                  <d:valid-sync-token/>
+                </d:error>
+              XML
+            end
+          when "addressbook-multiget"
+            # CARDDAV:addressbook-multiget (RFC 6352 section 8.7); the
+            # address-data the client asks for is section 10.4.
+            wants_cards = doc.xpath("//address-data").any?
+
+            multistatus(doc.xpath("//href").map { it.text }.map { |requested|
+              id = requested[%r{\A/dav/addressbook/([^/]+)\.vcf\z}, 1]
+              contact = id && contacts.find { it.id == id }
+
+              if contact
+                wants_cards ? card_response(contact) : etag_response(contact)
+              else
+                missing_response(requested)
+              end
+            })
+          else
+            # The DAV:supported-report precondition on REPORT (RFC 3253
+            # section 3.6) — the report asked for has to be one the
+            # resource supports. Answering an unsupported report with an
+            # empty 207 reads to the client as a successful empty result,
+            # and to us as nothing at all: 207 is not a status
+            # UnhandledRequests captures, so the one signal that a client
+            # wanted something unimplemented never fired.
+            #
+            # 403 with the precondition named in a DAV:error body is the
+            # marshalling RFC 4918 section 16 defines, and 403 is its
+            # "will always fail, do not repeat" case. addressbook-query
+            # (RFC 6352 section 8.6) is the report this rejects today;
+            # macOS Contacts has never sent one.
+            response.status = 403
+
+            <<~XML
+              <?xml version="1.0" encoding="UTF-8"?>
+              <d:error xmlns:d="DAV:">
+                <d:supported-report/>
+              </d:error>
+            XML
+          end
+        end
+
+        # Read on its own rather than through the collection: serving
+        # one href has no reason to load every other contact first.
+        r.get String do |filename|
+          contact = member(filename.delete_suffix(".vcf"))
+
+          if contact
+            response["Content-Type"] = "text/vcard; charset=utf-8"
+            # RFC 7232 section 2.3; must match the getetag reported for
+            # this contact in PROPFIND and REPORT.
+            response["ETag"] = contact.etag
+            contact.vcard.to_s
+          end
+        end
+
+        # PUT to an unmapped URI creates the card there and PUT to a
+        # mapped one replaces it (RFC 6352 section 6.3.2). macOS
+        # creates with If-None-Match: * and a UUID it mints into both
+        # the URI and the card's UID, and updates with If-Match
+        # carrying the strong etag it was last served — see
+        # docs/apple-contacts.md, "What a write looks like on the wire".
+        r.put String do |filename|
+          id = filename.delete_suffix(".vcf")
+          # A last segment that is not this server's <id>.vcf shape
+          # cannot address a resource here, created or read — the
+          # same fall-through-to-404 the GET handler gives it.
+          write_card(id) if id.match?(Contact::ID_FORMAT)
+        end
+
+        # DELETE removes the card at the member URI (RFC 4918 section
+        # 9.6) — the DAV:unbind privilege the collection advertises,
+        # and what iOS sends when a contact is deleted on the phone.
+        r.delete String do |filename|
+          id = filename.delete_suffix(".vcf")
+          remove_card(id) if id.match?(Contact::ID_FORMAT)
+        end
+      end
+    end
+
+    private
+
+    # DAV:current-user-principal (RFC 5397 section 3) — what a client
+    # asks the root and the well-known URI for to find the principal it
+    # is acting as.
+    #: (String href) -> String
+    def current_user_principal(href)
+      response["Content-Type"] = "text/xml"
+      response.status = 207
+
+      <<~XML
+        <?xml version="1.0" encoding="UTF-8"?>
+        <d:multistatus xmlns:d="DAV:">
+          <d:response>
+            <d:href>#{href}</d:href>
+            <d:propstat>
+              <d:prop>
+                <d:current-user-principal>
+                  <d:href>/dav/principal/</d:href>
+                </d:current-user-principal>
+              </d:prop>
+              <d:status>HTTP/1.1 200 OK</d:status>
+            </d:propstat>
+          </d:response>
+        </d:multistatus>
+      XML
+    end
+
+    # Read once per request — Roda builds a fresh app instance for each
+    # one — from the store the whole process shares. Reading a family
+    # address book per request is cheap and can never serve a stale one.
+    #
+    # The requester's book and nothing else: every route under /dav serves
+    # that collection (docs/plans/2026-09-12-per-user-books.md).
+    #: () -> Array[Contact]
+    def contacts
+      @contacts ||= store.contacts.select { book.include?(it.id) }
+    end
+
+    #: () -> Set[String]
+    def book
+      @book ||= store.book(@identity.name)
+    end
+
+    # One member of the requester's book, or nil for a card outside it
+    # as for one that does not exist: neither is a member of this
+    # collection.
+    #: (String id) -> Contact?
+    def member(id)
+      store.contact(id) if book.include?(id)
+    end
+
+    #: () -> String
+    def ctag
+      @ctag ||= store.ctag
+    end
+
+    # Sync tokens are opaque to the client (RFC 6578 section 3); the URI
+    # form is conventional. Built on the ctag so a client polling either
+    # one sees changes at the same points, and on the requester's name so
+    # that a token is refused by any other book, the one a rename leaves
+    # a user with included (docs/plans/2026-09-12-per-user-books.md, "The
+    # wire").
+    #: () -> String
+    def sync_token
+      "http://pro-tacts/sync/#{ctag}/#{book_digest}"
+    end
+
+    # The name half of a sync token: the first 16 hex digits of the
+    # SHA-256 of the requester's display name.
+    #: () -> String
+    def book_digest
+      Digest::SHA256.hexdigest(@identity.name)[0, 16].to_s
+    end
+
+    # The whole of the PUT route: a private method because a Roda route
+    # block cannot return early, so each refusal is a value the block
+    # ends with rather than a branch it exits. The checks run in the
+    # order RFC 7232 section 5 sets for a request with preconditions —
+    # the request's own validity first, the conditionals on stored
+    # state after — so a card that cannot be stored hears
+    # CARDDAV:valid-address-data even when its If-Match is stale too.
+    #: (String id) -> String?
+    def write_card(id)
+      # A card outside the requester's book is no member of this
+      # collection (#member), and its id cannot be created here either,
+      # being taken: the 404 the GET of it gets.
+      return if !book.include?(id) && store.contact(id)
+
+      # CARDDAV:supported-address-data (RFC 6352 section 6.3.2.1): what
+      # arrived must be a vCard, and text/vcard is the one media type
+      # this server stores. Asked before the body is read because it is
+      # a question about the request, not about what the request
+      # carried.
+      return precondition("supported-address-data") unless request.media_type == "text/vcard"
+
+      # Decoding the body, both halves of it: Rack requires input in
+      # ASCII-8BIT and Rack::RewindableInput enforces it again, so the
+      # bytes are relabelled rather than converted — untouched — and the
+      # label is checked honest in the same breath, because a
+      # force_encoding nobody validates is a lie every later reader
+      # inherits. That the bytes are not the UTF-8 the media type
+      # promised is a fact about this body, not about any card, which is
+      # why it is settled here and VCard is never handed the question
+      # (CARDDAV:valid-address-data, RFC 6352 section 6.3.2.1). Left
+      # unsettled it would surface as a 500 out of SQLite on the insert,
+      # past the point where the client could be told what was wrong.
+      # Paths need no counterpart: Puma hands PATH_INFO over still
+      # percent-encoded, so an id off the wire is ASCII.
+      bytes = request.body.read.force_encoding(Encoding::UTF_8)
+      return precondition("valid-address-data") unless bytes.valid_encoding?
+
+      # CARDDAV:valid-address-data again, on the card this time: the
+      # envelope RFC 2426 section 4 requires. That is the whole test. A
+      # line inside it this parser cannot read is not grounds for
+      # refusing the card: RFC 6352 section 6.3.2.2 has the server keep
+      # what it does not understand, and it is kept — the stored bytes
+      # are what goes back out.
+      vcard = VCard.new(bytes)
+      return precondition("valid-address-data") unless vcard.card?
+
+      # CARDDAV:no-uid-conflict: the submitted UID must not belong to a
+      # different resource, and a mapped URI must not be overwritten by
+      # a card carrying a different UID. Here the id is the card's UID
+      # (see Contact), so both clauses come down to: the card carries a
+      # UID naming the resource being written, and no other card claims
+      # it. The href in the body is the SHOULD that section attaches to
+      # the first clause — report where the UID already lives.
+      uid = vcard.uid
+      owner = uid && store.card_id_with_uid(uid)
+      return precondition("no-uid-conflict", owner) if uid != id || owner && owner != id
+
+      existing = store.contact(id)
+
+      # The lost-update conditionals, RFC 7232 sections 3.1 and 3.2.
+      # macOS sends If-Match on updates and If-None-Match: * on creates;
+      # a PUT carrying neither is unconditional and allowed to proceed.
+      if_match = request.env["HTTP_IF_MATCH"]
+      return plain_412 if if_match && !if_match_satisfied?(if_match, existing)
+
+      if_none_match = request.env["HTTP_IF_NONE_MATCH"]
+      return plain_412 if if_none_match && if_none_match_failed?(if_none_match, existing)
+
+      report_unreadable_lines(vcard)
+      report_broken_assumptions(vcard)
+
+      stored = store.put(id, vcard, sync_to: @identity.name)
+      response.status = existing ? 204 : 201
+      # A strong ETag belongs on the answer only when what the resource
+      # now serves is the submitted bytes, octet for octet — the one
+      # case RFC 6352 section 6.3.2.3 lets a client rely on the tag it
+      # gets back, and anywhere else it forbids one outright. Which is
+      # the composed card against the body, not the stored one: a
+      # birthday and a group's lines are both subtracted before storage
+      # and composed back in on read, so a member returning the card it
+      # downloaded stores fewer bytes than it sent and still gets the
+      # tag, while a PUT that carried a birthday somewhere other than
+      # where compose puts it back serves bytes that are not the ones
+      # it sent, and the client refetches.
+      response["ETag"] = stored.etag if stored.vcard.to_s == vcard.to_s
+
+      # A returned "" would land in the body and pin text/html and
+      # content-length onto the 204, which a bodyless status must not
+      # carry (Rack 3's lint rejects both); nil leaves it bodyless.
+      nil
+    end
+
+    # The whole of the DELETE, a private method for write_card's reason:
+    # a Roda route block cannot return early.
+    #: (String id) -> String?
+    def remove_card(id)
+      # An unmapped URI is a 404, not a silent success: RFC 4918 section
+      # 9.6 gives DELETE no idempotent status, and a client deleting what
+      # it believes exists is owed the disagreement. Falls through to the
+      # not_found handler the way the GET and PUT of a bad id do. A card
+      # outside the requester's book is unmapped here too (#member).
+      existing = member(id)
+      return unless existing
+
+      # The lost-update conditional, RFC 7232 section 3.1, on the same
+      # terms the PUT gets it: a client that names the representation it
+      # means to remove must not remove one that changed underneath it.
+      # No recorded session has sent one on a DELETE — the iOS delete
+      # carried none, which section 3.1 leaves unconditional and
+      # allowed, and macOS has sent no DELETE at all. Answered anyway,
+      # because ignoring the header is the lost update it exists to
+      # refuse, and the PUT's own check is right here to reuse.
+      if_match = request.env["HTTP_IF_MATCH"]
+      return plain_412 if if_match && !if_match_satisfied?(if_match, existing)
+
+      # The change-log entry Store#delete leaves in the same transaction
+      # is what a syncing client is told: sync-collection answers a
+      # removed member as href plus 404 (#missing_response).
+      store.delete(id)
+      response.status = 204
+
+      # Bodyless for the 204, write_card's reason.
+      nil
+    end
+
+    # A card that breaks one of the parser's assumptions is not bad
+    # input, it is news that the assumption is wrong and every
+    # simplification resting on it is now suspect — an error, where a
+    # line that merely would not read is ordinary bad input and warns
+    # instead (report_unreadable_lines, below). The card is accepted,
+    # stored, and served either way, and a line that will not read
+    # costs it only its index rows.
+    #
+    # Here rather than in the parser, which is where the break is
+    # found: a read happens on every look at a card — the admin index
+    # reads every contact on every page load — and one bad card must
+    # not alert once per page view. An arrival is the event worth a
+    # message, and a PUT is the only arrival there is. The message
+    # carries no card content (ProTacts::SentryScrubber's line); the
+    # admin view shows the card raw.
+    #: (VCard vcard) -> void
+    def report_broken_assumptions(vcard)
+      broken = vcard.lines.count { it.broke_assumption? }
+      return if broken.zero?
+
+      Sentry.capture_message(
+        "a submitted card broke #{broken} parser assumption(s) about what macOS Contacts sends",
+        level: :error,
+      )
+    end
+
+    # The ordinary half of the arrival reports: a line that would not
+    # read is bad input rather than news, so it warns — the level and
+    # the no-card-content line the store's BDAY reports already use.
+    # BrokenAssumption lines are excluded, so a line reports once; why
+    # this lives at the PUT is the comment above, and holds for both.
+    #: (VCard vcard) -> void
+    def report_unreadable_lines(vcard)
+      unreadable = vcard.lines.count { it.unreadable? }
+      return if unreadable.zero?
+
+      Sentry.capture_message(
+        "a submitted card carried #{unreadable} line(s) that would not read — stored verbatim, missing from the index",
+        level: :warning,
+      )
+    end
+
+    # A 412 whose body names the CardDAV precondition that failed, in
+    # the DAV:error form RFC 4918 section 16 defines; the element names
+    # are RFC 6352 section 6.3.2.1's, qualified by the card: namespace
+    # the body declares. no-uid-conflict carries the conflicting card's
+    # href when there is one.
+    #: (String element, ?String? conflict_id) -> String
+    def precondition(element, conflict_id = nil)
+      named = if conflict_id
+        "<card:#{element}><d:href>#{contact_href(conflict_id)}</d:href></card:#{element}>"
+      else
+        "<card:#{element}/>"
+      end
+      response.status = 412
+
+      <<~XML
+        <?xml version="1.0" encoding="UTF-8"?>
+        <d:error xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+          #{named}
+        </d:error>
+      XML
+    end
+
+    # A bare 412: these failures are HTTP's own conditionals (RFC 7232),
+    # not a WebDAV precondition with an element to name in a body.
+    #: () -> String
+    def plain_412
+      response.status = 412
+      ""
+    end
+
+    # RFC 7232 section 3.1: If-Match passes when the current etag is one
+    # of those listed, or, for `*`, when there is a current
+    # representation at all.
+    #: (String header, Contact? existing) -> bool
+    def if_match_satisfied?(header, existing)
+      return !existing.nil? if header.strip == "*"
+
+      !existing.nil? && header.split(",").map(&:strip).include?(existing.etag)
+    end
+
+    # RFC 7232 section 3.2: If-None-Match fails a non-GET request when
+    # the current etag is one of those listed, or, for `*`, whenever the
+    # resource exists. The create — If-None-Match: * against an unmapped
+    # URI — is the case CardDAV clients send (RFC 6352 section 6.3.2).
+    #: (String header, Contact? existing) -> bool
+    def if_none_match_failed?(header, existing)
+      return !existing.nil? if header.strip == "*"
+
+      !existing.nil? && header.split(",").map(&:strip).include?(existing.etag)
+    end
+
+    # A 207 body. The trailing DAV:sync-token is RFC 6578 section 3.2's
+    # — a sync-collection response MUST carry one naming the state the
+    # answer reaches; no other multistatus here has a state to name.
+    #: (Array[String] responses, ?sync_token: String) -> String
+    def multistatus(responses, sync_token: nil)
+      trailing = sync_token ? "  <d:sync-token>#{sync_token}</d:sync-token>\n" : ""
+      <<~XML
+        <?xml version="1.0" encoding="UTF-8"?>
+        <d:multistatus xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav">
+          #{responses.join}#{trailing}
+        </d:multistatus>
+      XML
+    end
+
+    #: (String id) -> String
+    def contact_href(id)
+      "/dav/addressbook/#{id}.vcf"
+    end
+
+    # DAV:getetag (RFC 4918 section 15.6).
+    #: (Contact contact) -> String
+    def etag_response(contact)
+      <<~XML
+        <d:response>
+          <d:href>#{contact_href(contact.id)}</d:href>
+          <d:propstat>
+            <d:prop>
+              <d:getetag>#{contact.etag}</d:getetag>
+            </d:prop>
+            <d:status>HTTP/1.1 200 OK</d:status>
+          </d:propstat>
+        </d:response>
+      XML
+    end
+
+    #: (Contact contact) -> String
+    def card_response(contact)
+      <<~XML
+        <d:response>
+          <d:href>#{contact_href(contact.id)}</d:href>
+          <d:propstat>
+            <d:prop>
+              <d:getetag>#{contact.etag}</d:getetag>
+              <card:address-data>#{xml_escape(contact.vcard.to_s.chomp)}</card:address-data>
+            </d:prop>
+            <d:status>HTTP/1.1 200 OK</d:status>
+          </d:propstat>
+        </d:response>
+      XML
+    end
+
+    # A member the collection does not answer for: the multiget miss,
+    # reported as a 404 inside the 207 rather than failing the request
+    # (RFC 6352 section 8.7), and the removal shape a sync-collection
+    # delta reports (RFC 6578 section 3.2 — href and 404, no propstat).
+    #: (String requested) -> String
+    def missing_response(requested)
+      <<~XML
+        <d:response>
+          <d:href>#{xml_escape(requested)}</d:href>
+          <d:status>HTTP/1.1 404 Not Found</d:status>
+        </d:response>
+      XML
+    end
+
+    # Text nodes in XML built by interpolation; hrefs and vCard content
+    # can all contain &, <, or >.
+    #: (String text) -> String
+    def xml_escape(text)
+      text.gsub(/[&<>]/, "&" => "&amp;", "<" => "&lt;", ">" => "&gt;")
+    end
+  end
+end
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
index 136ee66..6e77743 100644
--- a/sig/gems/roda.rbs
+++ b/sig/gems/roda.rbs
@@ -17,6 +17,12 @@ class Roda
   # The block is the router, run against a fresh instance per request.
   def self.route: () { (RodaRequest) [self: instance] -> untyped } -> void
 
+  # The hash_branches plugin's: a routing block for one next segment,
+  # compiled into a method on the app and run against the same instance
+  # the route block is, so it sees what the route set before it.
+  def self.hash_branch: (String segment) { (RodaRequest) [self: instance] -> untyped } -> void
+                      | (String | Symbol namespace, String segment) { (RodaRequest) [self: instance] -> untyped } -> void
+
   def request: () -> RodaRequest
   def response: () -> RodaResponse
 
@@ -57,6 +63,10 @@ class Roda
 
     # Ends the request with the response as it stands.
     def halt: () -> bot
+
+    # Dispatches to the hash_branch for the next segment, if one is
+    # defined, and ends the request with its answer.
+    def hash_branches: (?String | Symbol namespace) -> void
   end
 
   class RodaResponse