Type check lib with Steep, from RBS comments in the code
Method types sit above the methods they describe, as `#:` comments, so
they are read and changed with the implementation. `rake steep` runs the
check; it stays out of the default task, which is still the suite.

sig/ carries only what the inline syntax cannot say, and each file there
names the limit that put it there: the five gems that ship no signatures
of their own, the two Data classes whose super class is not a constant,
VCard's module_function, ProTacts' `class << self`, and the request
methods dav_verbs defines by evaluating a string. The gem signatures are
hand-written and cover only the surface this app calls, which keeps the
check free of a signature collection to install and a network fetch to
do it — and neither roda nor kdl is in that collection anyway.

Steep is pinned to 2.1.0.dev.1 because the released 2.0.0 parses Ruby as
3.3, where `it` is a method call rather than the block parameter.

Three things the checker turned up, none of them live bugs:

- A REPORT body that is not XML parses to a document with no root, and
  the handler read `doc.root.name` off it. It raises now: the same 500,
  with a reason in it.
- Reporting the first unknown key through `reject(...).first` is
  nil-shaped to a checker; `find` says it directly.
- `Regexp.last_match(1)` after a `when /\AHTTP_(.+)\z/` is `String?`
  even where the match cannot have failed. `delete_prefix` is the same
  value with no nil in it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Mijjo4aMQW3KucpVo8BhxT
change
commit 2da68d94e756012a3b4dab42d1b624e88a33c421
author Claude <noreply@anthropic.com>
date
parent wklxumvp
diff --git a/AGENTS.md b/AGENTS.md
index 63ab759..498a48e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -11,6 +11,7 @@ response — the shape of those responses is empirical, not arbitrary.
 
 ```bash
 rake                  # Tests (the default task)
+rake steep            # Type check lib against the RBS comments in it
 rake fixtures         # Re-record response fixtures from current behavior
 rake dev              # Dev server, reloading on change (needs fd and entr)
 rake profile:install  # Render and stage carddav.mobileconfig for approval
@@ -36,6 +37,7 @@ lib/pro_tacts/
 ├── profile.rb      # carddav.mobileconfig generation
 └── debug_logger.rb # Full request/response dumps, off by default
 lib/roda/plugins/dav_verbs.rb   # PROPFIND and REPORT routing verbs
+sig/                # RBS for what an inline comment cannot say
 docs/rfcs/          # Vendored spec texts the code cites
 docs/plans/         # Dated design records
 test/fixtures/macos-exchange/   # A real client session, replayed
@@ -89,3 +91,15 @@ before regenerating.
   current set was found by removing properties until the client broke.
 - `docs/plans/` entries are dated records of what was decided then. Write
   a new one rather than editing an old one to match current behavior.
+- Types live in the code, as RBS comments: `#:` above a method for its
+  type, `# @rbs` for instance variables and skips, `#:` at the end of a
+  line for a constant or an assertion. `rake steep` checks them, and
+  `sig/` holds only what that syntax cannot express — the gems, which
+  ship no signatures, and the classes the inline parser refuses. Each
+  file there says which limit put it there; see
+  docs/plans/2026-08-20-type-checking.md.
+- An instance variable declaration has to be the first thing in the
+  class body. Further down it is reported as an unused annotation.
+- `rake steep` runs the check with `-EUTF-8` because RBS reads source
+  in the default external encoding: under a C locale the em dashes in
+  these comments are invalid bytes and the parse dies on them.
diff --git a/Gemfile b/Gemfile
index 0c22137..b956271 100644
--- a/Gemfile
+++ b/Gemfile
@@ -17,4 +17,8 @@ group :development do
   gem "minitest"
   gem "rack-test"
   gem "ruby-lsp"
+  # Pinned to a prerelease: released Steep parses Ruby as 3.3, where `it`
+  # is a method call rather than the block parameter, so every block in
+  # lib/ fails to type check. 2.1.0.dev.1 is the first release parsing 3.4.
+  gem "steep", "2.1.0.dev.1"
 end
diff --git a/Gemfile.lock b/Gemfile.lock
index af2c003..2b6902d 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -8,24 +8,33 @@ GIT
 GEM
   remote: https://rubygems.org/
   specs:
+    ast (2.4.3)
     base64 (0.2.0)
     bigdecimal (3.1.9)
     concurrent-ruby (1.3.6)
+    csv (3.3.6)
     date (3.5.1)
     erb (6.0.1)
     ffi (1.17.4-aarch64-linux-gnu)
     ffi (1.17.4-arm64-darwin)
+    ffi (1.17.4-x86_64-linux-gnu)
+    fileutils (1.8.0)
     io-console (0.8.2)
     irb (1.16.0)
       pp (>= 0.6.0)
       rdoc (>= 4.0.0)
       reline (>= 0.4.2)
+    json (2.21.2)
     kdl (2.2.0)
       base64 (~> 0.2.0)
       bigdecimal (~> 3.1.6)
       racc (~> 1.5)
       simpleidn (~> 0.2.1)
     language_server-protocol (3.17.0.6)
+    listen (3.10.0)
+      logger
+      rb-fsevent (~> 0.10, >= 0.10.3)
+      rb-inotify (~> 0.9, >= 0.9.10)
     logger (1.7.0)
     minitest (6.0.1)
       prism (~> 1.5)
@@ -34,6 +43,11 @@ GEM
       racc (~> 1.4)
     nokogiri (1.19.0-arm64-darwin)
       racc (~> 1.4)
+    nokogiri (1.19.0-x86_64-linux-gnu)
+      racc (~> 1.4)
+    parser (3.3.12.0)
+      ast (~> 2.4.1)
+      racc
     pp (0.6.3)
       prettyprint
     prettyprint (0.2.0)
@@ -49,7 +63,11 @@ GEM
       rack (>= 1.3)
     rackup (2.3.1)
       rack (>= 3)
+    rainbow (3.1.1)
     rake (13.3.1)
+    rb-fsevent (0.11.2)
+    rb-inotify (0.11.1)
+      ffi (~> 1.0)
     rbs (4.1.3)
       logger
       prism (>= 1.6.0)
@@ -66,16 +84,41 @@ GEM
       language_server-protocol (~> 3.17.0)
       prism (>= 1.2, < 2.0)
       rbs (>= 3, < 5)
+    securerandom (0.4.1)
     sentry-ruby (6.2.0)
       bigdecimal
       concurrent-ruby (~> 1.0, >= 1.0.2)
     simpleidn (0.2.3)
+    steep (2.1.0.dev.1)
+      concurrent-ruby (>= 1.1.10)
+      csv (>= 3.0.9)
+      fileutils (>= 1.1.0)
+      json (>= 2.1.0)
+      language_server-protocol (>= 3.17.0.4, < 4.0)
+      listen (~> 3.0)
+      logger (>= 1.3.0)
+      parser (>= 3.2)
+      prism (>= 0.25.0)
+      rainbow (>= 2.2.2, < 4.0)
+      rbs (~> 4.0)
+      securerandom (>= 0.1)
+      strscan (>= 1.0.0)
+      terminal-table (>= 2, < 5)
+      uri (>= 0.12.0)
     stringio (3.2.0)
+    strscan (3.1.8)
+    terminal-table (4.0.0)
+      unicode-display_width (>= 1.1.1, < 4)
     tsort (0.2.0)
+    unicode-display_width (3.2.0)
+      unicode-emoji (~> 4.1)
+    unicode-emoji (4.2.0)
+    uri (1.1.1)
 
 PLATFORMS
   aarch64-linux
   arm64-darwin-25
+  x86_64-linux
 
 DEPENDENCIES
   hegeltest!
@@ -91,25 +134,34 @@ DEPENDENCIES
   roda
   ruby-lsp
   sentry-ruby
+  steep (= 2.1.0.dev.1)
 
 CHECKSUMS
+  ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
   base64 (0.2.0) sha256=0f25e9b21a02a0cc0cea8ef92b2041035d39350946e8789c562b2d1a3da01507
   bigdecimal (3.1.9) sha256=2ffc742031521ad69c2dfc815a98e426a230a3d22aeac1995826a75dabfad8cc
   concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab
+  csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
   date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
   erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5
   ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
   ffi (1.17.4-arm64-darwin) sha256=19071aaf1419251b0a46852abf960e77330a3b334d13a4ab51d58b31a937001b
+  ffi (1.17.4-x86_64-linux-gnu)
+  fileutils (1.8.0) sha256=8c6b1df54e2540bdb2f39258f08af78853aa70bad52b4d394bbc6424593c6e02
   hegeltest (0.0.0)
   io-console (0.8.2) sha256=d6e3ae7a7cc7574f4b8893b4fca2162e57a825b223a177b7afa236c5ef9814cc
   irb (1.16.0) sha256=2abe56c9ac947cdcb2f150572904ba798c1e93c890c256f8429981a7675b0806
+  json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
   kdl (2.2.0) sha256=46e56c6686dbb681625a0d6c6193e4b732d6627e8758f8d5e9295309ded958e6
   language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
+  listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2
   logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203
   minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb
   nio4r (2.7.5) sha256=6c90168e48fb5f8e768419c93abb94ba2b892a1d0602cb06eef16d8b7df1dca1
   nokogiri (1.19.0-aarch64-linux-gnu) sha256=11a97ecc3c0e7e5edcf395720b10860ef493b768f6aa80c539573530bc933767
   nokogiri (1.19.0-arm64-darwin) sha256=0811dfd936d5f6dd3f6d32ef790568bf29b2b7bead9ba68866847b33c9cf5810
+  nokogiri (1.19.0-x86_64-linux-gnu)
+  parser (3.3.12.0) sha256=21a6d7f755d5a24dfbdc6e6b772e4e879a52e7631a88bc5a3a134606052c9828
   pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6
   prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193
   prism (1.8.0) sha256=84453a16ef5530ea62c5f03ec16b52a459575ad4e7b9c2b360fd8ce2c39c1254
@@ -119,16 +171,26 @@ CHECKSUMS
   rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6
   rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463
   rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868
+  rainbow (3.1.1) sha256=039491aa3a89f42efa1d6dec2fc4e62ede96eb6acd95e52f1ad581182b79bc6a
   rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c
+  rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe
+  rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e
   rbs (4.1.3) sha256=0c4474a9751cdc14364bfad0b3e53678323bbdc2c31683b0445932867dbab8c4
   rdoc (7.0.3) sha256=dfe3d0981d19b7bba71d9dbaeb57c9f4e3a7a4103162148a559c4fc687ea81f9
   reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
   roda (3.100.0) sha256=35d36f43c68d2bd1974dc77ade8c873558265a191018e1ada88ca662f1fa8e62
   ruby-lsp (0.26.10) sha256=e67284af94423531f6b9a583350596421b5a6a4dd93083f1c2ba03da7c23bbed
+  securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
   sentry-ruby (6.2.0) sha256=d7b8a358a3a0d0536bd194b19cc282c85f295b6d242731e9b4a934ccdb71ac17
   simpleidn (0.2.3) sha256=08ce96f03fa1605286be22651ba0fc9c0b2d6272c9b27a260bc88be05b0d2c29
+  steep (2.1.0.dev.1) sha256=af0b17c8d45a5e76d66e5218512db21e13e6a173e0763a109c2af97511cd5215
   stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
+  strscan (3.1.8) sha256=aae2db611a225559f21ffbb71765c9a4e60fd262534a9ea84f4f11c7f32f679e
+  terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2
   tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
+  unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42
+  unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f
+  uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6
 
 BUNDLED WITH
   4.0.3
diff --git a/Rakefile b/Rakefile
index 0ff7be3..af045d4 100644
--- a/Rakefile
+++ b/Rakefile
@@ -25,6 +25,15 @@ task :fixtures do
   ExchangeFixtures.record_responses(ProTacts::Web)
 end
 
+desc "Type check lib against the RBS comments in it"
+task :steep do
+  # RBS reads source with the default external encoding, so under a C
+  # locale every em dash in a comment is an invalid byte rather than a
+  # character and the check dies parsing them. The same footgun the
+  # ASCII-only rule in config.ru exists for.
+  sh({"RUBYOPT" => "#{ENV.fetch('RUBYOPT', '')} -EUTF-8"}, "steep", "check")
+end
+
 desc "Render the macOS configuration profile (carddav.mobileconfig)"
 task profile: "carddav.mobileconfig"
 
diff --git a/Steepfile b/Steepfile
new file mode 100644
index 0000000..d4ca4ea
--- /dev/null
+++ b/Steepfile
@@ -0,0 +1,14 @@
+
+# Type checking for lib/. Signatures live in the code as RBS comments
+# (`#:` before a method, `# @rbs` for everything else), so the type and
+# the implementation are read and changed together.
+#
+# sig/ holds only what inline comments cannot express: the gems we call
+# into, which ship no signatures of their own, and the two Data classes
+# (see sig/pro_tacts/). Both are documented where they are defined.
+target :lib do
+  check "lib", inline: true
+  signature "sig"
+
+  library "digest", "fileutils", "logger", "pathname"
+end
diff --git a/docs/plans/2026-08-20-type-checking.md b/docs/plans/2026-08-20-type-checking.md
new file mode 100644
index 0000000..2cf6e8d
--- /dev/null
+++ b/docs/plans/2026-08-20-type-checking.md
@@ -0,0 +1,100 @@
+# Type checking with inline RBS and Steep
+
+2026-08-20. Types for `lib/`, written as comments in the code rather than
+as a parallel signature tree, and checked by `rake steep`.
+
+## The shape
+
+A method's type sits above it, in the comment a reader is already
+looking at:
+
+```ruby
+#: (KDL::Document document, uid: String) -> String
+def render(document, uid:)
+```
+
+`# @rbs` carries what a method type cannot — instance variables, and the
+`skip` that hands a declaration over to `sig/`. A trailing `#:` gives a
+constant its type or asserts one the checker cannot infer.
+
+`rake steep` runs the check. It is not part of the default task: the
+suite stays the thing `rake` runs, and this is a second opinion, not a
+gate the tests wait on. Moving it under `default` is a one-line change
+if that turns out to be the wrong split.
+
+## Why comments rather than a sig tree
+
+A parallel `sig/**/*.rbs` is a second copy of every method in the
+project, and the two drift the moment anything is renamed. Everything
+this project keeps — the RFC citations, the reasons a property is in a
+response — is already written next to the code it explains, and types
+are the same kind of note.
+
+## What sig/ is still for
+
+Five things the inline syntax cannot say. Each file there names the one
+that put it there:
+
+| File | Limit |
+|---|---|
+| `sig/gems/*.rbs` | kdl, nokogiri, rack, roda and sentry-ruby ship no signatures |
+| `sig/pro_tacts/contact.rbs`, `addressbook.rbs` | `class X < Data.define(...)` has no constant super class to read |
+| `sig/pro_tacts/vcard.rbs` | `module_function` has no inline spelling; `extend VCard` says it |
+| `sig/pro_tacts.rbs` | `class << self` is not read |
+| `sig/roda/plugins/dav_verbs.rbs` | methods defined by `class_eval` on a string |
+
+The Data classes are the only real loss: their whole signature lives in
+`sig/`, away from the code, because `@rbs skip` takes the method types
+with it. Their bodies are still checked against it.
+
+## Why the gem signatures are hand-written
+
+gem_rbs_collection would mean `rbs collection install`, a second
+lockfile, and a network fetch before the checker can run — and it
+carries neither roda nor kdl, which are the two that matter here. Each
+stub is a few dozen lines covering only what this app calls. Anything
+called that is missing from one is a type error rather than a silent
+`untyped`, which is the point.
+
+Two of them narrow reality on purpose, and say so where they do it:
+Sentry events stay `untyped`, because `SentryScrubber` feature-detects
+its way through whatever `before_send` hands it, and Roda's matchers
+stay `untyped`, because the vocabulary is open and the block arguments
+depend on which matcher matched. `r.get String do |filename|` yields an
+untyped filename.
+
+## Steep is pinned to a prerelease
+
+`steep 2.0.0` parses Ruby as 3.3, where `it` is a method call rather
+than the block parameter, so every block in `lib/` fails. `2.1.0.dev.1`
+is the first release parsing 3.4. Unpin it when 2.1.0 ships.
+
+## Encoding
+
+RBS reads source files in the default external encoding, so under a C
+locale every em dash in these comments is an invalid byte and the check
+dies inside the parser. `rake steep` sets `-EUTF-8` for the run. This is
+the same footgun the ASCII-only rule in `config.ru` exists for.
+
+## What the checker found
+
+- `Nokogiri::XML` returns a document with no root when the body is not
+  XML, and the REPORT handler read `doc.root.name` straight out. It
+  raises now, which is the same 500 with a legible reason.
+- Two spots reported the first unknown key with `reject(...).first`,
+  which is nil-shaped to a checker. `find` says the same thing directly.
+- `Regexp.last_match(1)` after a `when /\AHTTP_(.+)\z/` is `String?`
+  even where the match cannot have failed; `key.delete_prefix("HTTP_")`
+  is the same value with no nil in it.
+
+None of these were live bugs. The nil-root one was the closest.
+
+## What is not checked
+
+`test/`, the `Rakefile` and `config.ru`. Tests are already the check on
+themselves, and typing them means signatures for minitest and rack-test
+before anything is learned.
+
+One thing to know about the boundary: a file Steep cannot parse is
+skipped without a word, so a syntax error reads as a clean check. `rake`
+is what catches that.
diff --git a/lib/pro_tacts/addressbook.rb b/lib/pro_tacts/addressbook.rb
index 51e11be..dba85a1 100644
--- a/lib/pro_tacts/addressbook.rb
+++ b/lib/pro_tacts/addressbook.rb
@@ -8,6 +8,10 @@ module ProTacts
   # The ctag changes when any card is added, removed, or changed and
   # nothing else, so a client comparing two of them learns whether a
   # resync is needed — never what changed.
+  #
+  # The signature lives in sig/pro_tacts/addressbook.rbs, for the same
+  # reason Contact's does.
+  # @rbs skip
   class Addressbook < Data.define(:contacts)
     def self.load(directory)
       new(contacts: Contact.all(directory))
diff --git a/lib/pro_tacts/config.rb b/lib/pro_tacts/config.rb
index ad22d55..bcda6d9 100644
--- a/lib/pro_tacts/config.rb
+++ b/lib/pro_tacts/config.rb
@@ -6,8 +6,11 @@ module ProTacts
   # Nothing else in the app should read ENV directly; add a method here and
   # read it through ProTacts.config instead.
   class Config
-    TRUTHY = /\A(1|true|yes)\z/i
+    # @rbs @env: Hash[String, String]
 
+    TRUTHY = /\A(1|true|yes)\z/i #: Regexp
+
+    #: (?Hash[String, String] env) -> void
     def initialize(env = ENV)
       @env = env
     end
@@ -15,12 +18,14 @@ module ProTacts
     # Sentry DSN; nil when unset. A nil DSN is passed straight to
     # Sentry.init, which leaves the client inert — capture_message and
     # the rack middleware become no-ops.
+    #: () -> String?
     def sentry_dsn
       @env.fetch("SENTRY_DSN", nil)
     end
 
     # Whether to dump full request/response exchanges to the log. Off by
     # default because it logs contact data. See ProTacts::DebugLogger.
+    #: () -> bool
     def debug?
       value = @env.fetch("PRO_TACTS_DEBUG", nil)
       !value.nil? && value.match?(TRUTHY)
@@ -28,6 +33,7 @@ module ProTacts
 
     # Root data directory: holds the contacts directory and, later, the
     # database. Overridable with PRO_TACTS_DATA_DIR.
+    #: () -> Pathname
     def data_dir
       Pathname.new(@env.fetch("PRO_TACTS_DATA_DIR", "data"))
     end
@@ -35,6 +41,7 @@ module ProTacts
     # Contacts live at data/contacts, one KDL file per contact; the
     # filename is the contact ID. See
     # docs/plans/2026-01-12-carddav-architecture.md.
+    #: () -> Pathname
     def contacts_dir
       data_dir / "contacts"
     end
@@ -42,12 +49,14 @@ module ProTacts
     # Where requests the app could not answer are kept, one directory per
     # distinct request. Under log/ because it holds request data and is not
     # meant to be committed. See ProTacts::UnhandledRequests.
+    #: () -> Pathname
     def unhandled_dir
       Pathname.new(@env.fetch("PRO_TACTS_UNHANDLED_DIR", "log/unhandled"))
     end
 
     # Where the debug logger writes. A path, overridable with
     # PRO_TACTS_DEBUG_LOG; "stderr" keeps it on the process's stderr.
+    #: () -> String
     def debug_log_path
       @env.fetch("PRO_TACTS_DEBUG_LOG", "log/debug.log")
     end
diff --git a/lib/pro_tacts/contact.rb b/lib/pro_tacts/contact.rb
index 29e0c1a..3470470 100644
--- a/lib/pro_tacts/contact.rb
+++ b/lib/pro_tacts/contact.rb
@@ -15,6 +15,10 @@ module ProTacts
   # changes the bytes without changing the card. It is stored in the
   # entity-tag's quoted form (RFC 7232 section 2.3), which is what both
   # the ETag header and getetag properties carry.
+  #
+  # The signature lives in sig/pro_tacts/contact.rbs: a Data class has
+  # no constant super class for the inline syntax to read.
+  # @rbs skip
   class Contact < Data.define(:id, :vcard, :etag)
     # Ids end up in paths and arrive from client-supplied hrefs, so a
     # filename outside this charset cannot be served.
diff --git a/lib/pro_tacts/debug_logger.rb b/lib/pro_tacts/debug_logger.rb
index b278ada..b1c2d6e 100644
--- a/lib/pro_tacts/debug_logger.rb
+++ b/lib/pro_tacts/debug_logger.rb
@@ -14,15 +14,19 @@ module ProTacts
   # fictional, so the debug path stays verbose while the normal one-line path
   # (Roda's common_logger) can be narrowed later without losing this.
   class DebugLogger
+    # @rbs @app: Rack::_App
+    # @rbs @logger: Logger
+
     # Builds the Logger the middleware writes to: appended and unbuffered
     # (Logger syncs its own device), one timestamped line per dump. path
     # "stderr" writes to the process's stderr.
+    #: (String | Pathname path) -> Logger
     def self.open_log(path)
       target = if path.to_s == "stderr"
                  $stderr
                else
                  FileUtils.mkdir_p(Pathname.new(path).dirname)
-                 path
+                 path.to_s
                end
 
       Logger.new(target).tap do |logger|
@@ -33,11 +37,13 @@ module ProTacts
       end
     end
 
+    #: (Rack::_App app, logger: Logger) -> void
     def initialize(app, logger:)
       @app = app
       @logger = logger
     end
 
+    #: (Rack::env env) -> Rack::response
     def call(env)
       log_request(env)
       status, headers, body = @app.call(env)
@@ -47,6 +53,7 @@ module ProTacts
 
     private
 
+    #: (Rack::env env) -> void
     def log_request(env)
       write(">>", "#{env.fetch('REQUEST_METHOD')} #{full_path(env)} #{env.fetch('SERVER_PROTOCOL')}")
       each_header(env) do |name, value|
@@ -56,31 +63,39 @@ module ProTacts
       write(">>", body) unless body.empty?
     end
 
+    # Returns the body parts it consumed, for the caller to send on in
+    # place of the body it read.
+    #: (Integer status, Rack::headers headers, Rack::_Body body) -> Array[String]
     def log_response(status, headers, body)
       write("<<", "#{status}#{reason(status)}")
       headers.each do |name, value|
         write("<<", "#{name}: #{value}")
       end
-      parts = []
+      parts = [] #: Array[String]
       body.each do |part|
         parts << part
       end
-      body.close if body.respond_to?(:close)
+      # A body holding a resource closes it. No signature can say
+      # "close if you have one", so the cast carries what respond_to?
+      # has already established.
+      (_ = body).close if body.respond_to?(:close)
       write("<<", parts.join) unless parts.join.empty?
       parts
     end
 
+    #: (Rack::env env) -> String
     def full_path(env)
       path = env["PATH_INFO"].to_s
       query = env["QUERY_STRING"].to_s
       query.empty? ? path : "#{path}?#{query}"
     end
 
+    #: (Rack::env env) { (String, untyped) -> void } -> void
     def each_header(env)
       env.each do |key, value|
         case key
         when /\AHTTP_(.+)\z/
-          yield header_name(Regexp.last_match(1)), value
+          yield header_name(key.delete_prefix("HTTP_")), value
         when "CONTENT_TYPE"
           yield "Content-Type", value
         when "CONTENT_LENGTH"
@@ -89,10 +104,12 @@ module ProTacts
       end
     end
 
+    #: (String name) -> String
     def header_name(name)
       name.split("_").map(&:capitalize).join("-")
     end
 
+    #: (Rack::env env) -> String
     def read_request_body(env)
       input = env["rack.input"]
       return "" if input.nil?
@@ -101,11 +118,13 @@ module ProTacts
       body
     end
 
+    #: (Integer status) -> String
     def reason(status)
       phrase = Rack::Utils::HTTP_STATUS_CODES[status]
       phrase ? " #{phrase}" : ""
     end
 
+    #: (String prefix, String text) -> void
     def write(prefix, text)
       text.to_s.lines(chomp: true).each do |line|
         @logger.debug("#{prefix} #{line}")
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
index 9defea3..a06af58 100644
--- a/lib/pro_tacts/profile.rb
+++ b/lib/pro_tacts/profile.rb
@@ -21,6 +21,7 @@ module ProTacts
     # injects (see ProTacts::TailscaleAuth). They stay in the template
     # because the account form expects the fields; dropping them is
     # untested.
+    #: (hostname: String) -> String
     def self.render(hostname:)
       identifier = "#{IDENTIFIER_PREFIX}-#{unique_hex}"
 
@@ -38,10 +39,14 @@ module ProTacts
     # Scans for the prefix anywhere in the output rather than assuming a
     # key-value layout, since the listing format has changed across macOS
     # versions (key-value today, table under later releases).
+    #: (String list_output) -> Array[String]
     def self.installed_identifiers(list_output)
-      list_output.scan(/(?<![\w.-])#{Regexp.escape(IDENTIFIER_PREFIX)}-[\w.-]+/).uniq
+      # A pattern with no groups scans to whole matches, which is
+      # narrower than the signature of String#scan can say.
+      list_output.scan(/(?<![\w.-])#{Regexp.escape(IDENTIFIER_PREFIX)}-[\w.-]+/).uniq #: Array[String]
     end
 
+    #: () -> String
     def self.template
       <<~XML
         <?xml version="1.0" encoding="UTF-8"?>
@@ -99,14 +104,17 @@ module ProTacts
     # CardDAVPrincipalURL is omitted on purpose: no Server Path, matching
     # the bare-hostname setup the working session used.
 
+    #: (String text) -> String
     def self.escape(text)
       text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
     end
 
+    #: () -> String
     def self.unique_hex
       "#{Time.now.utc.strftime('%Y%m%d%H%M%S%L')}#{rand(1 << 16).to_s(16)}"
     end
 
+    #: () -> String
     def self.uuid
       [8, 4, 4, 4, 12].map { |n| Array.new(n) { HEX[rand(16)] }.join }.join("-")
     end
diff --git a/lib/pro_tacts/sentry_scrubber.rb b/lib/pro_tacts/sentry_scrubber.rb
index b2961e7..66ea3db 100644
--- a/lib/pro_tacts/sentry_scrubber.rb
+++ b/lib/pro_tacts/sentry_scrubber.rb
@@ -16,10 +16,11 @@ module ProTacts
     # lazy quantifier, whose stopping point depends on alternation order.
     VCARD = /BEGIN:VCARD(?~END:VCARD)(?:END:VCARD|\z)/mi
     VCARD_CONTENT_TYPE = %r{\Atext/vcard}i
-    REDACTED = "[vcard redacted]".freeze
+    REDACTED = "[vcard redacted]".freeze #: String
 
     # Sentry discards the event unless a Sentry::ErrorEvent comes back, so
     # this returns the event it was handed either way.
+    #: (untyped event, ?untyped hint) -> untyped
     def self.call(event, _hint = nil)
       request = event.request if event.respond_to?(:request)
       return event unless request
@@ -36,6 +37,7 @@ module ProTacts
 
     # A card PUT is a card whether or not it parses, so the content type is
     # enough on its own.
+    #: (untyped request) -> bool
     def self.vcard_body?(request)
       headers = request.headers
       return false unless headers.respond_to?(:[])
@@ -44,6 +46,7 @@ module ProTacts
     end
 
     # Form bodies arrive as a params hash rather than a string.
+    #: (untyped data) -> untyped
     def self.redact(data)
       case data
       when String then data.gsub(VCARD, REDACTED)
diff --git a/lib/pro_tacts/tailscale_auth.rb b/lib/pro_tacts/tailscale_auth.rb
index 603cfbe..13b9bf8 100644
--- a/lib/pro_tacts/tailscale_auth.rb
+++ b/lib/pro_tacts/tailscale_auth.rb
@@ -14,6 +14,8 @@ module ProTacts
   # Any tailnet identity is accepted. Getting onto the tailnet is the access
   # control; the address book has no per-user view to protect.
   class TailscaleAuth
+    # @rbs @app: Rack::_App
+
     LOGIN_HEADER = "HTTP_TAILSCALE_USER_LOGIN"
     NAME_HEADER = "HTTP_TAILSCALE_USER_NAME"
 
@@ -21,10 +23,12 @@ module ProTacts
     # to know who is asking.
     IDENTITY = "pro_tacts.user"
 
+    #: (Rack::_App app) -> void
     def initialize(app)
       @app = app
     end
 
+    #: (Rack::env env) -> Rack::response
     def call(env)
       login = env[LOGIN_HEADER].to_s.strip
 
@@ -38,6 +42,7 @@ module ProTacts
 
     private
 
+    #: () -> Rack::response
     def forbidden
       body = "Forbidden: no Tailscale identity on this request.\n"
       [403, { "Content-Type" => "text/plain", "Content-Length" => body.bytesize.to_s }, [body]]
diff --git a/lib/pro_tacts/unhandled_requests.rb b/lib/pro_tacts/unhandled_requests.rb
index 1cc24b1..c556b90 100644
--- a/lib/pro_tacts/unhandled_requests.rb
+++ b/lib/pro_tacts/unhandled_requests.rb
@@ -14,25 +14,33 @@ module ProTacts
   # This is the local counterpart to the Sentry reporting in config.ru, which
   # no longer sends request bodies.
   class UnhandledRequests
+    # @rbs @app: Rack::_App
+    # @rbs @directory: Pathname
+
     # 404 is the missing-functionality signal: a client asked for something
     # this server does not route. 5xx is kept because Sentry now reports
     # those without a body, and a crash is hard to read without one.
+    #: (Integer status) -> bool
     def self.capture?(status)
       status == 404 || status >= 500
     end
 
+    #: (Rack::_App app, directory: Pathname | String) -> void
     def initialize(app, directory:)
       @app = app
       @directory = Pathname.new(directory)
     end
 
+    #: (Rack::env env) -> Rack::response
     def call(env)
       status, headers, body = @app.call(env)
       return [status, headers, body] unless self.class.capture?(status)
 
-      parts = []
+      parts = [] #: Array[String]
       body.each { parts << it }
-      body.close if body.respond_to?(:close)
+      # See DebugLogger#log_response: respond_to? cannot narrow an
+      # interface, so the cast stands for the check beside it.
+      (_ = body).close if body.respond_to?(:close)
 
       capture(env, status, headers, parts.join)
 
@@ -41,6 +49,7 @@ module ProTacts
 
     private
 
+    #: (Rack::env env, Integer status, Rack::headers headers, String body) -> void
     def capture(env, status, headers, body)
       target = @directory / name_for(env)
 
@@ -57,52 +66,65 @@ module ProTacts
       warn "pro-tacts: could not record unhandled request: #{e.message}"
     end
 
+    #: (Rack::env env) -> String
     def name_for(env)
       slug = env["PATH_INFO"].to_s.gsub(%r{[^\w]+}, "-").delete_prefix("-").delete_suffix("-")
       slug = "root" if slug.empty?
       "#{env['REQUEST_METHOD'].to_s.downcase}-#{slug}-#{digest(env)}"
     end
 
+    #: (Rack::env env) -> String
     def digest(env)
-      Digest::SHA256.hexdigest([env["REQUEST_METHOD"], env["PATH_INFO"], request_body(env)].join("\n"))[0, 8]
+      # Slicing a hexdigest cannot come up short, which String#[] has
+      # no way to promise.
+      Digest::SHA256.hexdigest([env["REQUEST_METHOD"], env["PATH_INFO"], request_body(env)].join("\n"))[0, 8] #: String
     end
 
+    #: (Rack::env env) -> String
     def render_request(env)
       lines = ["#{env['REQUEST_METHOD']} #{full_path(env)} #{env.fetch('SERVER_PROTOCOL', 'HTTP/1.1')}"]
       lines += each_header(env).map { |name, value| "#{name}: #{value}" }
       message(lines, request_body(env))
     end
 
+    #: (Integer status, Rack::headers headers, String body) -> String
     def render_response(status, headers, body)
       message([status.to_s] + headers.map { |name, value| "#{name}: #{value}" }, body)
     end
 
+    #: (Array[String] lines, String body) -> String
     def message(lines, body)
       head = lines.join("\n") + "\n\n"
       body = body.to_s.chomp
       body.empty? ? head : "#{head}#{body}\n"
     end
 
+    #: (Rack::env env) -> String
     def full_path(env)
       path = env["PATH_INFO"].to_s
       query = env["QUERY_STRING"].to_s
       query.empty? ? path : "#{path}?#{query}"
     end
 
+    #: (Rack::env env) -> Array[[String, untyped]]
     def each_header(env)
       env.filter_map { |key, value|
         case key
-        when /\AHTTP_(.+)\z/ then [header_name(Regexp.last_match(1)), value]
+        when /\AHTTP_(.+)\z/ then [header_name(key.delete_prefix("HTTP_")), value]
         when "CONTENT_TYPE" then ["Content-Type", value]
         when "CONTENT_LENGTH" then ["Content-Length", value]
         end
-      }.sort
+      # The two-element arrays are pairs, which filter_map has no way
+      # to say.
+      }.sort #: Array[[String, untyped]]
     end
 
+    #: (String name) -> String
     def header_name(name)
       name.split("_").map(&:capitalize).join("-")
     end
 
+    #: (Rack::env env) -> String
     def request_body(env)
       input = env["rack.input"]
       return "" if input.nil?
diff --git a/lib/pro_tacts/vcard.rb b/lib/pro_tacts/vcard.rb
index 4b6ce0f..785f67f 100644
--- a/lib/pro_tacts/vcard.rb
+++ b/lib/pro_tacts/vcard.rb
@@ -14,9 +14,9 @@ module ProTacts
     # character.
     LINE_LIMIT = 75
 
-    NAME_COMPONENTS = %w[family given additional prefix suffix].freeze
-    ADDRESS_PARTS = %w[street city state zip country].freeze
-    CONTACT_FIELDS = %w[name phone email address].freeze
+    NAME_COMPONENTS = %w[family given additional prefix suffix].freeze #: Array[String]
+    ADDRESS_PARTS = %w[street city state zip country].freeze #: Array[String]
+    CONTACT_FIELDS = %w[name phone email address].freeze #: Array[String]
 
     # Properties each node accepts; anything else is a typo silently
     # dropping data, so it raises.
@@ -24,26 +24,28 @@ module ProTacts
       "phone" => %w[type],
       "email" => %w[type],
       "address" => %w[type],
-    }.freeze
+    }.freeze #: Hash[String, Array[String]]
 
     TEXT_ESCAPES = {
       "\\" => "\\\\",
       ";" => "\\;",
       "," => "\\,",
       "\n" => "\\n",
-    }.freeze
+    }.freeze #: Hash[String, String]
 
     module_function
 
+    #: (KDL::Document document, uid: String) -> String
     def render(document, uid:)
       nodes = document.nodes
       validate_children(nodes, CONTACT_FIELDS, "contact")
 
       names = nodes.select { it.name == "name" }
-      raise ArgumentError, "contact requires a name" if names.empty?
+      name = names.first
+      raise ArgumentError, "contact requires a name" if name.nil?
       raise ArgumentError, "contact takes a single name" if names.length > 1
 
-      n, fn = name_fields(names.first)
+      n, fn = name_fields(name)
 
       lines = [
         "BEGIN:VCARD",
@@ -68,6 +70,7 @@ module ProTacts
     # suffix; empty parts skipped). Providing both is an error: they are
     # two spellings of the same truth, and silently preferring one would
     # hide the disagreement. Returns [N components, FN].
+    #: (KDL::Node name) -> [Array[String], String]
     def name_fields(name)
       validate_children(name, NAME_COMPONENTS, "name")
       validate_properties(name)
@@ -100,6 +103,7 @@ module ProTacts
       end
     end
 
+    #: (Array[KDL::Node] nodes, String kdl_name, String vcard_name) -> Array[String]
     def typed_property_lines(nodes, kdl_name, vcard_name)
       nodes.select { it.name == kdl_name }.map { |node|
         validate_properties(node)
@@ -112,13 +116,18 @@ module ProTacts
     # ADR's seven components in order: pobox, extended address, street,
     # locality, region, postal code, country. The first two have no KDL
     # counterpart and stay empty.
+    #: (Array[KDL::Node] nodes) -> Array[String]
     def address_lines(nodes)
       nodes.select { it.name == "address" }.map { |node|
         validate_children(node, ADDRESS_PARTS, "address")
         validate_properties(node)
         parts = node.children
           .select { ADDRESS_PARTS.include?(it.name) }
-          .to_h { [it.name, string_argument(it)] }
+          .to_h do |part|
+            # A two-element literal is an Array until something says
+            # otherwise, and to_h takes pairs.
+            [part.name, string_argument(part)] #: [String, String]
+          end
 
         components = ["", "", *ADDRESS_PARTS.map { parts.fetch(it, "") }]
         type = node.properties["type"]&.value
@@ -131,11 +140,13 @@ module ProTacts
     # sub-component separator (RFC 2426 section 2.4.2); CRLF and CR are
     # normalized to the `\n` escape because a raw line break would end
     # the property line.
+    #: (String text) -> String
     def escape(text)
       text.gsub(/\r\n|\r/, "\n").gsub(/[\\;,\n]/) { TEXT_ESCAPES.fetch(it) }
     end
 
     # Escapes each component, then joins with the component separator.
+    #: (Array[String] values) -> String
     def components(values)
       values.map { escape(it) }.join(";")
     end
@@ -144,6 +155,7 @@ module ProTacts
     # octets, each continuation starting with a single space (RFC 2426
     # section 2.6). The walk is character-wise so a multibyte character
     # is never split mid-sequence.
+    #: (String line) -> String
     def fold(line)
       return line if line.bytesize <= LINE_LIMIT
 
@@ -160,6 +172,7 @@ module ProTacts
       folded
     end
 
+    #: (KDL::Node node) -> String
     def string_argument(node)
       argument = node.arguments.first
       raise ArgumentError, "#{node.name} requires a string argument" if argument.nil?
@@ -167,13 +180,16 @@ module ProTacts
       argument.value.to_s
     end
 
+    # Takes a node list or a single node, whose children it walks.
+    #: (Array[KDL::Node] | KDL::Node nodes, Array[String] known, String context) -> void
     def validate_children(nodes, known, context)
-      unknown = nodes.reject { known.include?(it.name) }
-      return if unknown.empty?
+      unknown = nodes.find { !known.include?(it.name) }
+      return if unknown.nil?
 
-      raise ArgumentError, "unknown key in #{context}: #{unknown.first.name}"
+      raise ArgumentError, "unknown key in #{context}: #{unknown.name}"
     end
 
+    #: (KDL::Node node) -> void
     def validate_properties(node)
       allowed = ALLOWED_PROPERTIES.fetch(node.name, [])
       unknown = node.properties.keys - allowed
diff --git a/lib/pro_tacts/web.rb b/lib/pro_tacts/web.rb
index 73604c8..7b3aa4c 100644
--- a/lib/pro_tacts/web.rb
+++ b/lib/pro_tacts/web.rb
@@ -14,6 +14,8 @@ require "roda/plugins/dav_verbs"
 
 module ProTacts
   class Web < Roda
+    # @rbs @addressbook: Addressbook?
+
     # RewindableInput allows us to read the request body for Sentry logging
     # and then rewind it so the application can still access it.
     use Rack::RewindableInput::Middleware
@@ -212,7 +214,13 @@ module ProTacts
             doc = Nokogiri::XML(body)
             doc.remove_namespaces!
 
-            if doc.root.name == "sync-collection"
+            # 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?
+
+            if root.name == "sync-collection"
               # DAV:sync-collection (RFC 6578 section 3.2). The warm-sync ask
               # is etag-only; a changed etag sends the client back through
               # multiget, so no address-data here.
@@ -272,15 +280,18 @@ module ProTacts
     # one — with the directory coming from config. Real etags and ctags
     # make an mtime-keyed cache possible, but re-parsing a family address
     # book per request is cheap and can never serve a stale change tag.
+    #: () -> Addressbook
     def addressbook
       @addressbook ||= Addressbook.load(ProTacts.config.contacts_dir)
     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>
@@ -295,6 +306,7 @@ module ProTacts
       XML
     end
 
+    #: (Contact contact) -> String
     def card_response(contact)
       <<~XML
         <d:response>
@@ -312,6 +324,7 @@ module ProTacts
 
     # An href with no match is reported as a 404 inside the 207 rather than
     # failing the request (RFC 6352 section 8.7).
+    #: (String requested) -> String
     def missing_response(requested)
       <<~XML
         <d:response>
@@ -323,6 +336,7 @@ module ProTacts
 
     # 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
diff --git a/sig/gems/kdl.rbs b/sig/gems/kdl.rbs
new file mode 100644
index 0000000..0a43eca
--- /dev/null
+++ b/sig/gems/kdl.rbs
@@ -0,0 +1,36 @@
+# The kdl gem ships no signatures, so this describes the parts pro-tacts
+# calls: a document is nodes, a node is a name with arguments, properties
+# and children, and every leaf value is wrapped in a KDL::Value.
+#
+# Written against kdl 2.2.0 (lib/kdl/document.rb, node.rb, value.rb).
+module KDL
+  # KDL 2.0 scalars: strings, numbers, booleans, and null.
+  type value = String | Integer | Float | bool | nil
+
+  def self.parse: (String input, **untyped options) -> Document
+
+  class Document
+    include Enumerable[Node]
+
+    attr_accessor nodes: Array[Node]
+
+    def each: () { (Node) -> void } -> void
+  end
+
+  class Node
+    include Enumerable[Node]
+
+    attr_accessor name: String
+    attr_accessor arguments: Array[Value]
+    attr_accessor properties: Hash[String, Value]
+    attr_accessor children: Array[Node]
+
+    # Yields the children, which is what makes a node interchangeable
+    # with a node list wherever pro-tacts walks one.
+    def each: () { (Node) -> void } -> void
+  end
+
+  class Value
+    attr_reader value: KDL::value
+  end
+end
diff --git a/sig/gems/nokogiri.rbs b/sig/gems/nokogiri.rbs
new file mode 100644
index 0000000..b7798cf
--- /dev/null
+++ b/sig/gems/nokogiri.rbs
@@ -0,0 +1,28 @@
+# The nokogiri surface pro-tacts uses to read REPORT bodies: parse, drop
+# the namespaces, and pull elements out by xpath.
+#
+# `root` is declared non-nil because Nokogiri::XML only returns a
+# document without one for input that is not XML at all, and web.rb
+# raises on that case explicitly rather than reading a nil root.
+module Nokogiri
+  def self.XML: (String string_or_io) -> Nokogiri::XML::Document
+
+  module XML
+    class Node
+      def name: () -> String
+      def text: () -> String
+      def xpath: (String) -> NodeSet
+    end
+
+    class Document < Node
+      def root: () -> Node?
+      def remove_namespaces!: () -> Document
+    end
+
+    class NodeSet
+      include Enumerable[Node]
+
+      def each: () { (Node) -> void } -> void
+    end
+  end
+end
diff --git a/sig/gems/rack.rbs b/sig/gems/rack.rbs
new file mode 100644
index 0000000..b37c5ab
--- /dev/null
+++ b/sig/gems/rack.rbs
@@ -0,0 +1,45 @@
+# Rack ships no signatures. This names the vocabulary the middleware in
+# lib/pro_tacts speaks — the environment, the response triple, and an
+# app to delegate to — plus the two constants used directly: the status
+# phrases the debug logger prints, and the middleware config.ru's
+# comment explains (web.rb mounts it so a body can be read twice).
+module Rack
+  # The CGI-ish hash a middleware is called with. Values stay untyped:
+  # anything upstream may put anything in it, which is why the code
+  # reads it through fetch and to_s.
+  type env = Hash[String, untyped]
+
+  type headers = Hash[String, String]
+
+  # What every middleware returns and what every middleware in this app
+  # passes along, rebuilt from parts once the body has been consumed.
+  type response = [Integer, headers, _Body]
+
+  # A response body: enumerable once, and closeable if it holds a
+  # resource — which only respond_to? can tell you, so it is here and
+  # close is not.
+  interface _Body
+    def each: () { (String) -> void } -> void
+    def respond_to?: (Symbol name) -> bool
+  end
+
+  # The request body, as env["rack.input"] carries it.
+  interface _Input
+    def read: () -> String
+    def rewind: () -> void
+  end
+
+  # The app a middleware wraps.
+  interface _App
+    def call: (env) -> response
+  end
+
+  module Utils
+    HTTP_STATUS_CODES: Hash[Integer, String]
+  end
+
+  class RewindableInput
+    class Middleware
+    end
+  end
+end
diff --git a/sig/gems/roda.rbs b/sig/gems/roda.rbs
new file mode 100644
index 0000000..4748381
--- /dev/null
+++ b/sig/gems/roda.rbs
@@ -0,0 +1,40 @@
+# Roda ships no signatures. This is the routing surface web.rb uses and
+# no more of it: the class methods that build the app, the request
+# methods that match, and the two response setters.
+#
+# Matchers stay untyped on purpose. Roda's matcher vocabulary — strings,
+# classes, regexps, symbols, hashes, and arrays of those — is open, and
+# the block arguments a match yields depend on which matcher matched.
+class Roda
+  def self.plugin: (Symbol | Module plugin, *untyped args) ?{ () [self: instance] -> untyped } -> void
+
+  def self.use: (untyped middleware, **untyped options) -> void
+
+  # The block is the router, run against a fresh instance per request.
+  def self.route: () { (RodaRequest) [self: instance] -> untyped } -> void
+
+  def request: () -> RodaRequest
+  def response: () -> RodaResponse
+
+  class RodaRequest
+    def env: () -> Rack::env
+    def body: () -> Rack::_Input
+
+    def is: (*untyped matchers) { (*untyped) -> untyped } -> void
+    def on: (*untyped matchers) { (*untyped) -> untyped } -> void
+    def get: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
+    def options: (*untyped matchers) ?{ (*untyped) -> untyped } -> void
+
+    # Halts the request, so nothing after it in a route block runs.
+    def redirect: (String path, ?Integer status) -> bot
+  end
+
+  class RodaResponse
+    def []=: (String header, String value) -> void
+    def status=: (Integer status) -> void
+  end
+
+  module RodaPlugins
+    def self.register_plugin: (Symbol name, Module plugin) -> void
+  end
+end
diff --git a/sig/gems/sentry.rbs b/sig/gems/sentry.rbs
new file mode 100644
index 0000000..198f223
--- /dev/null
+++ b/sig/gems/sentry.rbs
@@ -0,0 +1,14 @@
+# Sentry is reached at exactly two points: the exception-capturing
+# middleware web.rb mounts, and the message the not_found handler sends.
+#
+# Events are left untyped. SentryScrubber walks whatever before_send
+# hands it, feature-detecting as it goes, and typing that shape would
+# claim more about the sentry-ruby internals than the scrubber assumes.
+module Sentry
+  def self.capture_message: (String message, ?level: Symbol) -> untyped
+
+  module Rack
+    class CaptureExceptions
+    end
+  end
+end
diff --git a/sig/pro_tacts.rbs b/sig/pro_tacts.rbs
new file mode 100644
index 0000000..d5d4cc3
--- /dev/null
+++ b/sig/pro_tacts.rbs
@@ -0,0 +1,9 @@
+# `class << self` is outside what inline RBS reads, so the module-level
+# config accessors are declared here.
+module ProTacts
+  self.@config: Config?
+
+  def self.config: () -> Config
+
+  def self.config=: (Config config) -> Config
+end
diff --git a/sig/pro_tacts/addressbook.rbs b/sig/pro_tacts/addressbook.rbs
new file mode 100644
index 0000000..f58f0d5
--- /dev/null
+++ b/sig/pro_tacts/addressbook.rbs
@@ -0,0 +1,15 @@
+# A Data class, like Contact: see sig/pro_tacts/contact.rbs for why the
+# signature is here rather than in the Ruby.
+module ProTacts
+  class Addressbook
+    attr_reader contacts: Array[Contact]
+
+    def self.new: (contacts: Array[Contact]) -> instance
+
+    def self.load: (Pathname | String directory) -> Addressbook
+
+    def ctag: () -> String
+
+    def sync_token: () -> String
+  end
+end
diff --git a/sig/pro_tacts/contact.rbs b/sig/pro_tacts/contact.rbs
new file mode 100644
index 0000000..b5ee3a4
--- /dev/null
+++ b/sig/pro_tacts/contact.rbs
@@ -0,0 +1,19 @@
+# `class Contact < Data.define(...)` has no constant for a super class,
+# which is what inline RBS needs to read one (RBS docs/data_and_struct.md
+# covers the pattern). The class is marked `@rbs skip` in contact.rb and
+# its signature written out here instead.
+module ProTacts
+  class Contact
+    ID_FORMAT: Regexp
+
+    attr_reader id: String
+    attr_reader vcard: String
+    attr_reader etag: String
+
+    def self.new: (id: String, vcard: String, etag: String) -> instance
+
+    def self.all: (Pathname | String directory) -> Array[Contact]
+
+    def self.parse: (Pathname | String path) -> Contact
+  end
+end
diff --git a/sig/pro_tacts/vcard.rbs b/sig/pro_tacts/vcard.rbs
new file mode 100644
index 0000000..9ffbeb0
--- /dev/null
+++ b/sig/pro_tacts/vcard.rbs
@@ -0,0 +1,8 @@
+# `module_function` copies every method onto the module itself, and
+# inline RBS has no syntax for that. Extending the module is how RBS
+# says the same thing, so the method types can stay inline in vcard.rb.
+module ProTacts
+  module VCard
+    extend VCard
+  end
+end
diff --git a/sig/roda/plugins/dav_verbs.rbs b/sig/roda/plugins/dav_verbs.rbs
new file mode 100644
index 0000000..a39dea0
--- /dev/null
+++ b/sig/roda/plugins/dav_verbs.rbs
@@ -0,0 +1,21 @@
+# The plugin defines its request methods by evaluating a string, which
+# no signature can be derived from, so they are declared here. Their
+# shape matches Roda's own verb methods: match on the request method,
+# and route the block if it matches.
+class Roda
+  module RodaPlugins
+    module DavVerbs
+      module RequestMethods
+        def propfind: (*untyped matchers) { (*untyped) -> untyped } -> void
+
+        def report: (*untyped matchers) { (*untyped) -> untyped } -> void
+      end
+    end
+  end
+
+  class RodaRequest
+    # RBS has no way to say "only once plugin :dav_verbs is loaded", and
+    # web.rb — the only app here — loads it.
+    include RodaPlugins::DavVerbs::RequestMethods
+  end
+end