Trim AGENTS.md to what the code does not already say
Commands lists only the everyday tasks and points at `rake -T` for the
rest. Layout keeps the entries whose role a filename does not give.
Gotchas are grouped, and each keeps its rule while its argument goes
where it already lives: the Store class comment, test_helper.rb,
vcard.rb and the plans. The phrases lib cites AGENTS.md by are kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KCkrBu2KqN7ncT3kvjCvfh
change
commit 0bb5a7202127c308534f29fb5d0b70a67a1ed459
author Claude <noreply@anthropic.com>
date
parent 086764c0
diff --git a/AGENTS.md b/AGENTS.md
index c173e90..e657c84 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -17,39 +17,12 @@ rake steep            # Type check lib against the RBS comments in it
 rake fixtures         # Re-record response fixtures from current behavior
 rake fixtures:extract # A logged exchange's request as a fixture step
                       # (EXCHANGE=id STEP=test/fixtures/<recording>/NN-name)
-rake index:rebuild    # Derive the parsed index again from the stored cards
-rake contacts:reid    # Move contacts whose ids are not server-minted
-                      # onto minted ones, rewriting each card's UID
-rake book:name        # Name a login's book and rename its sync group
-                      # (LOGIN=login NAME=name; no NAME clears it)
-rake db:dump          # Write the cards, birthdays, groups, and books
-                      # to data/dump and commit them to the repository
-                      # there (DUMP=path to move it)
-rake dev              # Dev server, seeded from test/fixtures/cards into a
-                      # throwaway tmpdir on every start, reloading on change
-                      # (needs fd and entr)
-rake console          # irb with the store open on the configured database
-                      # (console.rb loads it), after db:dump commits a
-                      # safety net for what the session might do there
-rake profile:install  # Download carddav.mobileconfig from the app and
-                      # stage it for approval
-rake profile:remove   # Remove the installed profiles pointing at it
-rake alpine:vendor    # Refresh the vendored Alpine from npm
-                      # (ALPINE=version)
-rake gloss:vendor     # Refresh the vendored Gloss CSS from a local
-                      # checkout (GLOSS=path)
+rake dev              # Dev server on the fixture book, reloading on
+                      # change (needs fd and entr)
 ```
 
-`rake profile:*` needs `PRO_TACTS_HOSTNAME` and touches installed system
-profiles, so leave those to the user.
-
-Importing is not a task at all: a .vcf is uploaded at `/import` in the
-app and saved there contact by contact, each row's own Save writing it
-through the store (docs/plans/2026-09-21-import-a-vcf.md,
-docs/plans/2026-09-23-merging-on-import.md). The `import:*` tasks and
-the Swift reader they drove Contacts.app with are gone.
-
-## Task management
+`rake -T` lists the rest. `rake profile:*` needs `PRO_TACTS_HOSTNAME`
+and touches installed system profiles, so leave those to the user.
 
 The backlog lives in `ranger`, not GitHub Issues. `RANGER_DEFAULT_BACKLOG`
 is already `pro-tacts` — don't pass `--backlog` or verify it.
@@ -58,291 +31,157 @@ is already `pro-tacts` — don't pass `--backlog` or verify it.
 
 ```
 lib/pro_tacts/
-├── web.rb          # The Roda app: the identity gate and the root;
-│                   # each first segment is a hash_branch in web/
-├── web/dav.rb      # Discovery and the address book: every DAV route
-│                   # and response body
-├── dav_xml.rb      # The elements those bodies are written with, one
-│                   # object per namespace
-├── web/contacts.rb # The card browser and its editor
-├── web/groups.rb   # The group screens and their editor
-├── web/setup.rb    # The device setup screen and its profile
-├── web/import.rb   # The import screens: a .vcf, uploaded and saved
-├── web/api.rb      # JSON for scripts: the group list
-├── import/         # Reading an uploaded .vcf, matching its cards to
-│                   # contacts the book has, and writing them
-├── admin/          # The Phlex views those screens render, plus
-│                   # card_form.rb, which reads a form back into a card
-├── store.rb        # Sequel over SQLite: cards, the change log, the
-│                   # group change log, and the derived index
-├── contact.rb      # the one model of a contact: id, vCard, etag,
-│                   # and the structured accessors over the card
-├── group.rb        # A group as a screen reads one, and the `sync:`
-│                   # rules: which names put cards in a book, and
-│                   # everyone's
-├── edited_contact.rb # A submitted card read against the contact it
-│                   # replaces: the card, birthday, and group edits
-│                   # Store#put writes
-├── birthday.rb     # A partial date, the one fact no card can hold
-├── birthday_line.rb # What a submitted BDAY line is, for EditedContact
-├── card_diff.rb    # The lines a write added and removed
-├── sync_token.rb   # The token a syncing client carries: minted
-│                   # at a ctag for a book, and read back
-├── vcard.rb        # vCard 3.0 escaping, and what a card is
-├── vcard/parser.rb # One card's bytes into lines
-├── proxy_auth.rb   # The login, off the header the proxy writes
-├── stub_login.rb   # That header, filled in where no proxy stands
-│                   # (demo.ru only)
-├── change_id.rb    # Ids in the letters jj spells a change id with
-├── config.rb       # Every environment read in the app
-├── profile.rb      # carddav.mobileconfig generation
-├── exchange_log.rb # Failed DAV exchanges, whole, under an id Sentry
-│                   # carries
-├── refusal_alerts.rb # DAV refusals as Sentry warnings, one per route
-└── warnings.rb     # $VERBOSE off for a block, for the phlex requires
-config.ru           # The deployment: the composition root
-demo.ru             # The fixture book behind a stub login, served by
-                    # the Fly preview and main apps (fly.toml)
+├── web.rb          # The Roda app; each first path segment is a
+│                   # hash_branch in web/
+├── web/dav.rb      # Every DAV route and response body, written
+│                   # through dav_xml.rb
+├── web/*.rb        # The admin screens, one file per section
+├── admin/          # The Phlex views they render; card_form.rb reads
+│                   # a form back into a card
+├── import/         # An uploaded .vcf: read, matched, and written
+├── store.rb        # Sequel over SQLite, and every write
+├── contact.rb      # The one model of a contact
+├── edited_contact.rb # A submission read against the contact it
+│                   # replaces, for Store#put
+├── vcard.rb        # A card: its bytes, and the writer's half
+└── vcard/parser.rb # The reading half
+config.ru           # The deployment, and the composition root
+demo.ru             # The fixture book behind StubLogin: the Fly apps
 dev.ru              # demo.ru, marked as a dev run, for `rake dev`
-lib/roda/plugins/dav_verbs.rb     # PROPFIND and REPORT routing verbs
-lib/sequel/extensions/sole.rb     # `first`, minus the ambiguity
-db/migrations/      # Sequel migrations, run on every store open
+db/migrations/      # Run on every store open
 sig/                # RBS for what an inline comment cannot say
-docs/DESIGN.md      # Who the admin screens are for, and how they look
+docs/DESIGN.md      # Who the admin screens are for
 docs/apple-contacts.md # What the Apple clients do off-spec
 docs/rfcs/          # Vendored spec texts the code cites
 docs/plans/         # Dated design records
-test/fixtures/cards/            # Seed cards for the test database
-test/fixtures/macos-exchange/   # A real client session, replayed
-test/fixtures/ios-exchange/     # Where a second client differs from it
+test/fixtures/      # Seed cards, and real client sessions replayed
 ```
 
 ## Fixtures
 
 `test/fixtures/macos-exchange/` replays the confirmed-working macOS
 Contacts session, and `test/fixtures/ios-exchange/` the steps where iOS
-sends something macOS does not — a namespace prefix spelled differently,
-a header it omits, a verb macOS never sends. Within each step directory
-the two files have opposite standing:
+differs from it. Within each step directory the two files have opposite
+standing:
 
 - `request` files are evidence of what a real client sent. Edit them only
   when a new recorded session supersedes this one.
 - `response` files are a snapshot of current behavior. Never hand-edit
-  them; change the app, run `rake fixtures`, and review the diff.
+  them; change the app, run `rake fixtures`, and read the diff before
+  regenerating. One you didn't intend is the suite doing its job.
 
-A fixture diff you didn't intend is the test suite doing its job. Read it
-before regenerating.
-
-`test/fixtures/cards/` holds the vCards the test database is seeded from.
-The database itself is built in a throwaway tmpdir on every run and is
-not a fixture; edit a `.vcf` to change what the replay serves.
+`test/fixtures/cards/` seeds the test database, which is built in a
+tmpdir on every run; edit a `.vcf` to change what the replay serves.
+Seeding goes through `Store#put` without `client:`, so a test store has
+no `sync:*` group; a test that needs everyone's book makes it.
 
 ## Gotchas
 
-- Response bodies in `web/dav.rb` are built with Nokogiri's builder through
-  `DavXml`, whose methods are the only elements the server can send. Inside
-  an element's block, `it` is the builder, not an enclosing block's value,
-  so name the outer block's parameter.
-- Every request needs a `Remote-User` header or it gets a 401.
-  `StubLogin` fills it with `alpha@example.com` when a request has none,
-  so under `rake dev` pass it only to ask as someone else. The security
-  of that rests on the app being reachable only through a proxy that
-  writes the header itself. `StubLogin` belongs only in `demo.ru`, in
-  front of the fixture book: never put it in `config.ru`, and never
-  serve real data without the proxy in front.
-- DAV exchanges that went wrong (a status of 400 or more but 401, a
-  crash, or a report to Sentry) are written whole to `log/exchange.log`,
-  each line prefixed by the id Sentry carries as the `exchange` tag.
-  When implementing something a client asked for, look there first, and
-  promote an exchange into `test/fixtures` with `rake fixtures:extract`,
-  which strips the identifying headers.
-- No request body reaches Sentry: `send_default_pii` is off in
-  `config.ru`, because card content never leaves the machine. The URL,
-  headers, exception messages, and `capture_message` text still do, so
-  keep card content out of all four.
-- Don't hide errors. A `rescue` that swallows an exception and returns a
-  fallback value reads as defensive but is actually the opposite: it turns
-  a bug or corrupt data into a silently wrong screen instead of a loud one.
-  Every request already runs under `Sentry::Rack::CaptureExceptions`, so an
-  unexpected failure that's allowed to raise is reported and visible —
-  rescuing it locally "to be safe" makes it neither. Rescue only what's an
-  ordinary, anticipated case with a real fallback to show (e.g. no row for
-  a requested id — see `Store#contact`'s 404 path), never "this might
-  fail, better catch it." A rescue that catches must still send it to
-  Sentry — `Sentry.capture_exception` at the minimum.
-- `config.ru` must stay ASCII-only; a test enforces it, because boot crashes
-  under a C locale otherwise. Watch for em dashes in comments.
-- `RUBYOPT=--enable-frozen-string-literal` is set in `.ramekin/config.kdl`.
-  String literals are frozen; mutating one raises.
-- `supported_http_methods` in `config/puma.rb` *replaces* Puma's default
-  method list rather than extending it. Any method the app answers must be
-  named there or Puma returns 501 from the HTTP parser, before Rack runs —
-  so the request never reaches the app and `ExchangeLog` cannot log it. Adding a route is two files, not one.
-- Application code reads configuration through `ProTacts.config` only; add
-  a method to `config.rb` rather than reaching for `ENV`. The Rakefile is
-  outside that rule and reads `PRO_TACTS_HOSTNAME` directly.
-- `config.ru` is the composition root. Requiring the app must stay free of
-  side effects — the test helper depends on that, and `config.ru` never
-  runs under test.
-- The code cites RFC sections next to each handler. Verify a section
-  number against the vendored text in `docs/rfcs/` before writing it; do
-  not cite from memory. Two properties macOS needs (`getctag`,
-  `push-transports`) are in no RFC at all.
+### DAV
+
+- Response bodies are built with Nokogiri's builder through `DavXml`,
+  whose methods are the only elements the server can send. Inside an
+  element's block, `it` is the builder, so name an outer block's
+  parameter.
 - Adding a property to a response is a real decision, not a freebie. The
   current set was found by removing properties until the client broke.
-- `cards` and `changes` carry timestamps; the index tables deliberately
-  do not, because their rows are thrown away and rebuilt. SQLite writes
-  the stamps, so nothing in Ruby should set one.
-- Schema changes are Sequel migrations under `db/migrations/`, applied
-  by `Store#migrate` on every open — there is no separate migrate task
-  to forget, and no way to serve from a database a deploy left behind.
-  Never edit a migration that has run anywhere; add the next one.
-- Those tables are `STRICT`, so every string column needs `text: true`:
-  Sequel's plain `String` is a varchar, which STRICT refuses. Strings
-  reach the store as UTF-8 by contract, and the adapter enforces it: the
-  sqlite3 gem encodes every bound value to UTF-8, so binary-flagged
-  bytes above 7 bits raise at the bind, while bytes that are not UTF-8
-  at all are refused by SQLite on the insert. The one binary input is
-  the request body — Rack requires input in ASCII-8BIT and
-  Rack::RewindableInput enforces it — relabelled to UTF-8 where it is
-  read, in `write_card`. Path-derived ids need no relabel: Puma hands
-  PATH_INFO over still percent-encoded (set verbatim from
-  REQUEST_PATH), so an id off the wire is ASCII.
-- The app is handed its store rather than reaching for one:
-  `config.ru` builds it and sets `ProTacts::Web.store`, and a test does
-  the same with a throwaway. There is no global `ProTacts.store`, so
-  nothing in a request depends on load order or on a lazy memo several
-  threads could race into. `Store.connect` is for a database that is not
-  the one being served — a fixture, a test, a task pointed elsewhere.
-- `test/test_helper.rb` sets `ENV` before it requires the app, and the
-  requires cannot all move to the top because of it: the app reads
-  configuration as it loads, since the exchange log is given its path at
-  class-definition time. Set it afterwards and the failed exchanges land
-  in `log/` instead of a tmpdir.
-- Only six things in the database cannot be rebuilt: the cards, the
-  change log, the birthdays, the groups, the books, and the group
-  change log — a partial date has no vCard 3.0 spelling, so the
-  birthday lives beside its card rather than in it
-  (docs/plans/2026-08-31-partial-birthdays.md);
-  a group's membership and the lines it lends are facts no stored card
-  carries (docs/plans/2026-08-24-vcard-storage-and-groups.md);
-  only the `books` row says whose book a name is for
-  (docs/plans/2026-09-19-books-in-the-dump.md); and the group change
-  log's detail is a fact about a write, gone the moment it lands
-  (docs/plans/2026-09-23-group-change-log.md). Everything else
-  is a projection of the cards that `rake index:rebuild` will make
-  again, so no repair is ever needed for it. A write that touches a
-  card must land its change-log entry in the same transaction,
-  because a client's sync token silently skips whatever the log
-  missed — and a write that touches a group must land its
-  group-change-log entry there too, because a history with a hole in
-  it is silently wrong the same way.
-- A read whose filter is meant to identify one row uses `sole`, not
-  `first` — `first` answers with one of several rather than saying the
-  filter was too loose. `sole` raises either way it is wrong:
-  `Sequel::NoMatchingRow` for none, `Sequel::Sole::TooManyRows` for
-  more. A caller for whom no row is ordinary rescues the first, which is
-  what `Store#contact` does for the 404 path. The store loads the
-  extension on every open.
-- Rescue `Sequel::NoMatchingRow` at the method, around its one
-  expression; do not fold it into a read that returns nil, which only
-  moves the check into a `row &&` at every caller. The exception is a
-  method whose rescue would also wrap a transaction and swallow what
-  that raises: read with `first` and return early there
-  (`Store#delete_group`).
-- An etag is derived from the card, never stored beside it: `Contact`
-  hashes what it serves, and its constructor is the only way to make
-  one — no second path takes an etag on trust. The etag in `changes` is
-  the exception and is not the same fact — it is what the card hashed
-  to at that write, which nothing can recompute once the card moves on.
-  Its `diff` is the same kind of fact and the same kind of exception
-  (`CardDiff`): the lines that write added and removed, of the card as
-  served, so the etag and the diff describe one download. It records a
-  card's lines and not its bytes — folding is normalized away — so it
-  says what a write did without being able to rebuild what it did it
-  to.
-- Nothing above `vcard/parser.rb` handles a parse error, and nothing
-  should start. `Parser.lines` is the only read: a line that will not
-  read comes back as a Line with no property. The card is served from
-  its bytes regardless, `VCard` and `Contact` answer from the lines
-  that read, and the index holds what this server understood. There is
-  no repair to make, which is why no caller is asked to look for one.
-- The one exception is the arrival reports at the PUT, and it is not
-  handling: a broken assumption is news that something this server was
-  built on is wrong and reports as an error, and an unreadable line is
-  ordinary bad input and warns (`Web#report_broken_assumptions`,
-  `Web#report_unreadable_lines`). They live at the PUT because that is
-  the arrival — a read happens on every page load, and one bad card
-  must not alert once per page view.
-- `vcard.rb` no longer raises on what it does not recognize, and must
-  not start again. It used to render a hand-edited format, where an
-  unknown key meant a typo silently losing data; it now reads stored
-  cards, where an unknown property means a client using the spec, and
-  RFC 6352 section 6.3.2.2 requires keeping it.
-- `escape` and `header_of` in `vcard.rb` are the writer's half of the
-  module, and the web editor's saves are rebuilt through them
-  (`admin/card_form.rb`). There is no `fold` beside them: a served card
-  is stored bytes going out untouched, and the editor writes the one
-  line it changed, so nothing has ever had a logical line to fold.
-  `Parser.unfold` is the reading half and does have callers.
-- `vcard` names a `VCard` and nothing else, and a card with no
-  qualifier to give it is named `vcard`. A `vcard` holding a card's
-  bytes is the confusion the rule exists to stop: the two sit one
-  `VCard.new` apart, and at a PUT that gap is the whole question —
-  the body arrives as octets and everything below the route wants
-  the card — so the octets are `bytes` (`Web#write_card`). A card
-  that needs saying which one keeps its own word (`stored`, `own`,
-  `rest`). The `cards` column and the `text/vcard` media type spell
-  it the same way and are neither of them variables.
-- A wrong name is fixed where it starts, in the change at hand: the
-  migration that renames the column, and every identifier that reads
-  it, rather than a follow-up. Check what the new word already means in
-  that class before taking it.
-- A class that is built once and only read back is a function wearing
-  a constructor, and it needs a noun the domain does not have. Put the
-  logic as a `def self.` on the module or class whose vocabulary it
-  already uses, helpers under `private_class_method` (`CardDiff`,
-  `Birthday`, `Admin::Format`). A new class holds a real domain thing
-  (`EditedContact` holds the contact before and the submission). If no
-  name fits, ask whether the thing should exist at all.
-- A screen, step, or button that only restates what another page holds
-  is deleted, not redesigned: land the flow on the real thing (the
-  group, the list, the record) instead. See docs/DESIGN.md.
-- `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.
-- Comments carry the reasoning; the code carries the rest. One that
-  restates the line below it has nothing to say, and neither does a
-  test comment that restates its test name — the names in `test/` are
-  sentences already. Where the reasoning is already written down, cite
-  it rather than re-derive it: a `docs/plans/` entry and the section
-  within it, an RFC section, or the method that owns the rule. Two
-  copies of an argument drift apart, and the plan holds the whole of
-  it.
-- 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.
-- Steep refuses three shapes that Ruby accepts. A bare `[]` or `{}`
-  needs a type unless it lands in a typed parameter: put it on its own
-  line with `#: Hash[String, Array[String]]` at the end. A literal of
-  several elements is an `Array`, not a tuple, so one meant as
-  `[String, String]` says so the same way. And a local that is nil on
-  one branch stays nilable after a later `is_a?` check, so return on
-  the nil before the value is made.
-- Tests seed their store through `Store#put` without `client:`, so a
-  test store has no `sync:*` group. A test that needs everyone's book
-  makes it.
-- Actions in `.github/workflows/` are pinned by commit, and the
-  comment beside a pin names a tag that is that commit, never a
-  branch: zizmor flags a mismatch as a code scanning comment while its
-  job stays green. `git ls-remote --tags` confirms the pair.
-- `rake steep` needs a UTF-8 locale, which comes from the environment
-  rather than anything in this repo. RBS reads source in the default
-  external encoding, so under a C locale the em dashes in these comments
-  are invalid bytes and the parse dies on them. The same footgun the
-  ASCII-only rule in `config.ru` exists for.
+- Cite RFC sections from the vendored text in `docs/rfcs/`, never from
+  memory. `getctag` and `push-transports` are in no RFC at all.
+- `supported_http_methods` in `config/puma.rb` *replaces* Puma's list:
+  a method not named there gets a 501 before Rack runs, so adding a verb
+  is two files.
+- Every request needs a `Remote-User` header or it gets a 401. That is
+  safe only because a proxy writes the header. `StubLogin` fills it in
+  for the fixture book in `demo.ru` and nowhere else: never in
+  `config.ru`, and never in front of real data.
+- Failed DAV exchanges are written whole to `log/exchange.log` under the
+  id Sentry carries as the `exchange` tag. Look there first when a
+  client asks for something, and promote one with
+  `rake fixtures:extract`.
+
+### Errors
+
+- Don't hide errors. Every request runs under Sentry, so a failure
+  allowed to raise is reported; a rescue "to be safe" turns it into a
+  silently wrong screen. Rescue only an ordinary case with a real
+  fallback, and report anything else you catch with
+  `Sentry.capture_exception`.
+- A read meant to find one row uses `sole`, which raises on none or
+  several, and a caller for whom none is ordinary rescues
+  `Sequel::NoMatchingRow` at the method (`Store#contact`). Don't turn
+  that into a nil-returning read and a `row &&` at every caller. Where
+  the rescue would also wrap a transaction, read with `first` and
+  return early instead (`Store#delete_group`).
+- Card content never reaches Sentry: `send_default_pii` is off, and the
+  URL, headers, exception messages, and `capture_message` text must not
+  carry it either.
+- Nothing above `vcard/parser.rb` handles a parse error. A line that
+  will not read comes back with no property, the card is served from
+  its bytes regardless, and unknown properties are kept
+  (RFC 6352 section 6.3.2.2).
+- The one exception is the arrival reports at the PUT
+  (`Web#report_broken_assumptions`, `Web#report_unreadable_lines`):
+  there and not on read, so one bad card does not alert once per page
+  view.
+
+### Store
+
+- What cannot be rebuilt, and the plan behind each, is listed in
+  `Store`'s class comment; everything else is an index
+  `rake index:rebuild` makes again. A write that touches a card lands
+  its change-log entry in the same transaction, and one that touches a
+  group its group-change-log entry, because a sync token or a history
+  silently skips whatever the log missed.
+- An etag is derived from the card, never stored beside it; `Contact`'s
+  constructor is the only way to make one. The etag and diff in
+  `changes` are what that write served, stored rather than derived
+  because nothing can recompute them once the card moves on.
+- Schema changes are new migrations under `db/migrations/`; never edit
+  one that has run anywhere. SQLite writes the timestamps, so Ruby never
+  sets one.
+- Those tables are `STRICT`, so every string column needs `text: true`.
+  Strings reach the store as UTF-8: the one binary input is the request
+  body, relabelled where `write_card` reads it, and the sqlite3 gem and
+  SQLite refuse anything else at the bind or the insert.
+- The app is handed its store (`ProTacts::Web.store`, set by `config.ru`
+  or a test); there is no global. Requiring the app must stay free of
+  side effects, and code reads configuration through `ProTacts.config`,
+  not `ENV`.
+
+### Code
+
+- `vcard` names a `VCard` and nothing else; a card's octets are `bytes`
+  (`Web#write_card`), and a card that needs saying which one keeps its
+  own word (`stored`, `rest`).
+- A wrong name is fixed where it starts, in the change at hand,
+  migration and all. Check what the new word already means in that
+  class before taking it.
+- A class built once and only read back is a function: put it as a
+  `def self.` on the module whose vocabulary it uses (`CardDiff`,
+  `Birthday`, `Admin::Format`). If no name fits, ask whether the thing
+  should exist at all.
+- A screen or button that only restates another page is deleted, and
+  the flow lands on the real thing (docs/DESIGN.md).
+- Comments carry the reasoning, never a restatement of the line below
+  or the test name. Cite reasoning written elsewhere (a `docs/plans/`
+  section, an RFC, the owning method) rather than re-deriving it.
+  Plans are dated records: write a new one rather than editing an old
+  one.
+- String literals are frozen (`RUBYOPT` in `.ramekin/config.kdl`).
+- `config.ru` must stay ASCII-only (a test enforces it), and
+  `rake steep` needs a UTF-8 locale: under a C locale the em dashes in
+  comments are invalid bytes.
+- Workflow actions are pinned by commit with a tag comment naming that
+  commit, never a branch; zizmor flags a mismatch as a code-scanning
+  comment while its job stays green.
+
+### Types
+
+- Types are inline RBS comments, checked by `rake steep`; `sig/` holds
+  only what that syntax cannot express, each file saying why
+  (docs/plans/2026-08-20-type-checking.md). An instance variable
+  declaration must come first in the class body.
+- Steep refuses three shapes Ruby accepts: a bare `[]` or `{}` outside a
+  typed parameter, a literal meant as a tuple, and a local narrowed
+  from nil after it was made. Give the first two their own line with a
+  trailing `#:` type, and return on the nil before making the third.