Current section

Files

Jump to
at_mcp decisions.md
Raw

decisions.md

# Decisions

One line per project ruling or load-bearing design decision. Each carries the date it landed, a status (settled / provisional / retired), the evidence that holds it, and, where it compensates for a defect somewhere else, what would retire it. A line marked `unverified` has a written reason but no test or code that would fail if it stopped being true. Dates are the commit that made the decision real; `git log -S` finds the rest.

## Current name

- 2026-10-02 · settled (deepfates, #111) · The package, OTP app and release are `at_mcp`; the Elixir namespace is `AtMcp`. An agent connects its own persistent AT Protocol identity. The old private repository and history stay private; a future public repository is a separately reviewed snapshot. Commands and environment names change explicitly (README, "Transition from Kite 0.1.2"); saved file names, formats and published MCP tool names remain unchanged. The `x-kite-account-did` header, `kite/publicWrite` annotation and bearer token prefix remain because existing clients and grants use them. The historical entries below retain their original names and evidence coordinates; current code lives under `lib/at_mcp` and tests under `test/at_mcp`.

## Ownership

- 2026-09-09 · settled · Kite owns one AT Protocol account per DID (one process per configured account today; aliases of a DID are the provisional exception under Writes): credentials, session and refresh, readiness and disconnect, the durable per-DID write quota, the MCP tool surface, and durable delivery of incoming activity. It does not own conversations, permission decisions or an agent's behavior. — `AGENTS.md`; `Kite.Effects`; `test/kite/effects_identity_test.exs`.
- 2026-09-13 · settled · Kite bounds writes per account per hour and never per turn; host tokens, `/turns`, `KITE_MAX_WRITES_PER_TURN` and per-turn counters were deleted, not flagged. — `Kite.Identities.start_identity_runtime/1` raises on turn options; `test/kite/account_quota_boundary_test.exs`.
- 2026-09-11 · settled · Which identities an installation holds is answered by an operator listener (`KITE_CONTROL_PORT`), not by an MCP tool, and the answer carries no credential. — `Kite.Control`; `test/kite/telos_test.exs` "no tool lets an agent enumerate the installation's identities".
- 2026-09-06 · settled · One tool a person does not have exists on purpose: `identity_status` (session and write quota). — `Kite.MCP.Server`; `test/kite/mcp_tools_test.exs`.
- 2026-09-23 · settled (owner) · There is no tool for declining to act; an agent that ends its turn without calling a write tool has not posted. — `Kite.MCP.Server`; `test/kite/mcp_tools_test.exs` "every tool returns structured content its declared schema accepts" lists the whole surface.
- 2026-09-10 · provisional · Lists, starter packs, pinned posts, thread gates, self-labels, reporting, bookmarks and direct messages are deferred until each arrives with the controls that make it safe; whether an agent may post an undescribed image is the owner's call. — `docs/DESIGN.md`; unverified (no test names the absence).

## Credentials and access

- 2026-10-07 · settled (#19) · Every setting an operator gives through the environment is read and checked once, in `config/runtime.exs`, with its default beside its check; `lib/` reads only the application environment. An unknown `AT_MCP_*` variable or a bad value stops startup and names it; `AtMcp.CLI.settings/0` prints each setting's value and source, secrets as set or unset. The account variables are `AT_MCP_HANDLE`, `AT_MCP_APP_PASSWORD` and `AT_MCP_SERVICE` (and `_2`); the earlier `BLUESKY_*` names still work with a warning, because existing client configurations and environment files use them. Test-harness variables use `TEST_*`, outside the namespace the check owns. — `config/runtime.exs`; `test/at_mcp/settings_test.exs`.
- 2026-10-05 · provisional (#5) · Explicit `AT_MCP_APPVIEW_READS=direct` routes application GETs requiring an AppView through short-lived method-bound service tokens. Default proxy routing, PDS-owned preferences and all writes retain their current paths. Fixed Network endpoints bound the prototype; arbitrary providers remain unproved. This qualifies the historical every-read-via-session-service rule below. — `AtMcp.ATProto.ServiceAuth`; `test/at_mcp/direct_service_auth_test.exs`.


- 2026-09-11 · settled · One loopback MCP endpoint (`KITE_MCP_PORT`, 4400) serves every identity; `Authorization: Bearer <grant>` decides which identity a request acts as; a request with no grant is `401`, never served. — `Kite.MCP.HTTP`; `test/kite/mcp_http_test.exs` "the endpoint refuses a caller that presents nothing"; `test/kite/telos_test.exs` "one endpoint serves every identity".
- 2026-09-11 · settled · `x-kite-account-did` confirms the binding and selects nothing. — `Kite.MCP.HTTP`; `test/kite/mcp_http_test.exs` "one listener serves every identity, and a grant reaches only its own".
- 2026-09-11 · settled · A grant's scope (`read` / `write` / `manage`) is derived from each tool's own `readOnlyHint` and `destructiveHint`, never from a hand-kept verb list; a tool with no hints is reached by no scope. — `Kite.Grants`; `test/kite/mcp_http_test.exs` "a grant's scope bounds the tools it reaches, from the tools' own hints".
- 2026-09-11 · settled · Grants live in their own 0600 file beside the accounts file and store only digests; deleting that file revokes everything and keeps every identity; removing an account revokes its grants. — `Kite.Grants`; `test/kite/grants_test.exs`.
- 2026-09-11 · settled · No authorization server, no TLS requirement, no introspection endpoint. The OAuth resource-server code was deleted rather than kept behind a flag. Bearer is kept as the shape so an issuer-signed token can arrive later without a client changing. — no `oauth` in `lib/`; `Kite.Grants` moduledoc.
- 2026-09-11 · settled (owner) · No OAuth, MCP's or ATProto's, for now; login is an app password. Retires when there is a multi-tenant reason, which is Haven's question rather than Kite's. — workshop `AGENTS.md` standing decisions.
- 2026-09-07 · settled · `kite-connect` is a stdio front onto the shared service and holds no account password. — `test/kite/shared_stdio_test.exs`; `test/kite/account_setup_test.exs`.
- 2026-09-11 · settled · `kite-connect` receives its grant in `KITE_GRANT`, never in argv. — `Kite.SharedConnection`; `test/kite/account_setup_test.exs` ("Not in argv, where every process on the machine could read it"); `test/kite/shared_stdio_test.exs`.
- 2026-09-11 · settled · A grant is a bearer token on loopback with no binding to the holding process: it separates what each client was given, not what each process could obtain. It is not a sandbox, and the write quota is not an authorization boundary. — `Kite.Grants` moduledoc; unverified as a test.

## Configuration

- 2026-09-11 · settled · An installation is configured one way: the accounts file that `kite-accounts` manages (`KITE_ACCOUNTS_FILE`, or the platform's user configuration directory). No environment variable adds, removes or reauthorizes an account behind it. — `test/kite/telos_test.exs` "an installation is configured one way".
- 2026-09-11 · settled · `BLUESKY_HANDLE` / `BLUESKY_APP_PASSWORD` (and the `_2` pair) are read once, only when no accounts file exists, to write one; a login that fails writes nothing. — `Kite.AccountSetup`; `test/kite/account_setup_test.exs` "an installation configured by BLUESKY_* migrates itself into the accounts file".
- 2026-09-11 · provisional · An accounts file that still carries a per-account `mcp_port` loads with the key dropped, because refusing it as corrupt would lose an operator their accounts; a caller passing one is refused; the next write leaves it out. Retire the line once no installation predates the one-endpoint cutover. — `Kite.AccountConfig`; `test/kite/account_config_test.exs` "a file that still carries per-account ports loads without them".
- 2026-09-11 · settled · A missing accounts file is an installation with no accounts and the service still starts; a file that cannot be read is reported as unreadable, never as empty. — `test/kite/telos_test.exs` "a configuration that cannot be read is reported as unreadable, not as empty".
- 2026-09-07 · settled · Private files (the accounts file first; grants, quota and state followed) are written 0600, read-modify-write under one lock; an insecure file or symlink is refused. — `test/kite/account_config_test.exs` "insecure files and symlinks rejected".
- 2026-09-11 · settled · That file handling lives in one module, `Kite.PrivateFile`, not repeated per store. — `Kite.PrivateFile`; `Kite.AccountConfig` and `Kite.Grants` call it.
- 2026-09-07 · settled · `kite-mcp` (stdio) owns a single-account runtime, stops on EOF, opens no port and collects nothing; it reads `KITE_STDIO_ENV_FILE` and ignores the service's `KITE_ENV_FILE`. — `Kite.Stdio`; `test/kite/stdio_startup_test.exs`; `test/kite/stdio_test.exs`.
- 2026-09-12 · settled · A network is installation-wide; reaching Bluesky and Delvetown at once is two installations, each with its own unit, environment file, accounts file, ports, grants and `RELEASE_NODE`. — `Kite.Network`; README "Run it", "A second installation"; `test/kite/account_release_test.exs` (a second installation runs beside the first).
- 2026-09-28 · settled · Erlang distribution is the release's local control channel (`rpc`, `remote`), never a transport between machines, and it uses no port mapper: a mapper shared by Kite, Dwell and Haven belonged to whichever release started it, and under systemd died with that unit. The service listens on 127.0.0.1 at `KITE_DIST_PORT` (4370 by default; Dwell 4380, Haven 4390), `rpc` and `remote` connect straight to it and listen on nothing, and a second installation sets its own port. OTP still offers the node's name to a mapper on 127.0.0.1 if one is running; nothing depends on it. A node name without a host is given a loopback one (`localhost`, or `127.0.0.1` under `RELEASE_DISTRIBUTION=name`), because the machine's hostname can resolve to an address where nothing listens. `eval` gets no port. — `rel/vm.args.eex`, `rel/remote.vm.args.eex`, `rel/env.sh.eex`; `scripts/foreground_boot_check.py` (started as the example plist starts it, with no mapper reachable, every operator command answers and the node listens on 127.0.0.1 alone); `test/kite/account_release_test.exs` "release account commands manage one daemon across live configuration changes" (with no mapper reachable, every operator command works; the service listens only on 127.0.0.1 at its distribution port and MCP endpoint; a second installation runs beside it) and "the distribution port is 4370 unless the environment file names another, and eval gets none".
- 2026-09-07 · retired 2026-09-29 · The service commands ship inside the release on its bundled runtime, and start and restart wait for application readiness rather than trusting the service manager's acceptance. — `test/kite/service_test.exs`. Retired: the service is the release's own `bin/kite start` under a unit from `rel/examples/`; `kite-service` is deleted (GroveResearch/kite#92).
- 2026-09-25 · retired 2026-09-29 · Kite installs and upgrades like ordinary software, and the owner's machine does what a stranger's does. `kite install` records the installation (`DATA/installations/LABEL.json`, DATA per `Kite.Installation.paths/3`: label, configuration file, accounts file, state directory, port, current and previous release) and every later command reads it: `kite upgrade [--release TAG|PATH]`, `kite rollback`, `kite status`, `kite-service`, `kite-accounts`, with no environment variable or installation path at the call site. A command knows its installation from the path of the release it runs from. The same interface is being built in Dwell and Haven. — `Kite.Installation`; `test/kite/upgrade_test.exs`; `test/kite/installation_test.exs`. Retired: replaced by the normal Elixir way below (GroveResearch/kite#88, #92): `kite install|upgrade|rollback|status`, the installation record and `Kite.Installation` are deleted. Do not re-adopt a custom installer.
- 2026-09-28 · retired 2026-09-29 · Kite installs, upgrades and runs the same way on macOS and Linux, with the service manager chosen when a command runs and never at build: `KITE_SERVICE_MANAGER` (`launchd`, `systemd` for a systemd user unit, `none` to print the foreground command), else launchd where `launchctl` exists, systemd where it is running, none otherwise; the record keeps the manager it was installed under and a variable naming another is refused. `none` means something other than Kite runs the service: Kite prints the command (through `current`) and never starts or stops it, and upgrade and rollback refuse while it runs. A manager that cannot say whether the service runs stops the command (`Kite.Service.Unknown`); systemd is checked reachable before anything is done. Upgrade and rollback sit above `Kite.Service` and are one path for all three. macOS paths and labels are unchanged; Linux uses XDG data and state and `~/.local/lib/kite`; `KITE_DATA_DIR` moves data and logs. A release finds its configuration through `ROOT/env_file`, a one-line path any shell can read, and on a Mac falls back to the JSON record through plutil until every installation has been switched once by a release that writes `env_file`. No system units. — `Kite.Service`; `Kite.Installation.paths/3`; `rel/env.sh.eex`; `test/kite/installation_test.exs` "on Linux, install, start, status, upgrade and stop go through a systemd user unit", "with no service manager, install prepares everything and prints the foreground command"; `test/kite/release_env_test.exs`; `test/kite/installed_release_test.exs` (run on Linux by `check.yml`). Retired: `Kite.Service` and its managers are deleted (GroveResearch/kite#92); systemd and launchd run the example units, and CI still publishes `linux-x86_64` built on Ubuntu 22.04.
- 2026-09-25 · retired 2026-09-29 · An upgrade verifies the release's SHA-256, waits until no write or delivery is in flight, backs up data and configuration, switches, migrates, starts and checks every configured account is ready; on any failure after the stop it restores the backup and restarts the previous release by itself. — `test/kite/upgrade_test.exs` "install A, upgrade to B, and a C that never becomes ready is rolled back to B with the data intact" fails with the restore removed and with the rollback path removed. Retired: the upgrade verb is deleted (GroveResearch/kite#92); the README's fetch checks the SHA-256, and CI's upgrade check runs the README's upgrade and rollback both ways.
- 2026-09-25 · retired 2026-09-29 · The backup is taken after the service stops, not before, so it holds exactly what the stopped service left; the cost is that restoring it undoes what the new release did while it ran (redelivered activity, a write no longer counted). Launchd runs `current/bin/kite start`, and switching release is moving `current` while stopped. Rollback keeps the data and falls back to the backup taken when the previous release was left only if that release is not ready on it. — `Kite.Installation` moduledoc; `test/kite/installation_test.exs` "an upgrade waits out writes in flight, then stops, backs up, switches, migrates, starts and checks". Retired: Kite takes no backup; no release changes the format of what it stores (GroveResearch/kite#92, `scripts/upgrade_rollback_check.py`).
- 2026-09-28 · retired 2026-09-29 · Every installation path (command links, releases, data, service definitions, backups, logs) derives from `Kite.Installation.home/1`: `HOME` when set, else the VM's home. The VM reads its home once at start while the release's shell scripts read `HOME`, so a VM whose `HOME` changed after start (a test) wrote into the home it started with, which is the real one. Install and upgrade tests refuse a scratch home that resolves to the real home and set `HOME` to theirs (`Kite.HomeGuard`). — `test/kite/installation_home_test.exs` (an install in a VM started with one scratch home and then given another as `HOME` fails with the VM's home used). Retired: no Kite code writes installation paths any more (GroveResearch/kite#92).
- 2026-09-29 · settled · Kite installs and runs the normal Elixir way. CI builds and publishes the release; a machine runs `bin/kite start` under systemd or launchd from the example units in `rel/examples/` (shipped in the release as `examples/`), with settings from one environment file of `NAME=value` lines (a value with a space double-quoted) that carries its own `RELEASE_COOKIE`; a command run from a shell loads the same file when `KITE_ENV_FILE` names it; upgrading is `gh release download`, unpacking beside the running release with `tar --no-same-owner`, repointing `current` and restarting; rolling back is repointing and restarting. README's "Run it" is the only install documentation. Release tarballs record root as every entry's owner. — Owner ruling (workshop `AGENTS.md`, "Programs install and run the normal Elixir way"; GroveResearch/kite#88, #92); `rel/examples/`; `rel/env.sh.eex`; `test/kite/release_env_test.exs`; `scripts/foreground_boot_check.py`; `scripts/upgrade_rollback_check.py` (run under the example systemd unit and the example launchd plist by `check.yml`); `scripts/tarball_owner_check.py`.
- 2026-09-25 · retired 2026-09-29 · A renamed or moved configuration key is rewritten by the upgrade, never refused: the old file is kept as `NAME.before-BUILD` and what changed is printed without values. Every such rename is one line in `Kite.Migration`; the three `HAVEN_*` delivery variables are the first. — `test/kite/migration_test.exs`. Retired: retired with the upgrade that ran it (GroveResearch/kite#92): `Kite.Migration` is deleted. The per-account `mcp_port` drop stays in the loader (above); the `HAVEN_*` delivery variables were renamed at 8bf7a7d and are no longer read or rewritten.

- 2026-09-25 · settled (owner) · Kite's MCP tool names and argument shapes are a public boundary. A published tool or argument stays served with its type and nothing becomes newly required; a rename serves old and new names, the old one's entry carries `remove_after` and its description names that date (ninety days by default), and after the date both go. — `test/support/tool_surface.json`; `test/kite/tool_surface_test.exs`.

## Network and records

- 2026-09-11 · settled · The network is a value (`Kite.Network`, `KITE_NETWORK`); every NSID Kite names derives from it. A literal `app.bsky` anywhere else in `lib/` is a defect. `com.atproto.*` and `chat.bsky.*` stay literal because the rename does not touch them. — `test/kite/network_test.exs`; `test/kite/telos_test.exs` "the network Kite talks to is a value, not a literal".
- 2026-09-11 · settled · An unrecognized `KITE_NETWORK` raises; there is no fallback to Bluesky. — `test/kite/network_test.exs` "an unrecognized network raises rather than falling back".
- 2026-09-11 · settled · Post text limits are read from the lexicon at compile time, never transcribed. `priv/lexicons` holds copies of `feed/post.json` for each network because neither namespace is a dependency of Kite. Retires when a dependency ships those lexicons. — `priv/lexicons/README.md`; `test/kite/network_test.exs` "are the numbers in each network's own lexicon".
- 2026-09-11 · settled · Kite builds every record it writes (`Kite.ATProto`) and sends it with `com.atproto.repo.createRecord`; nothing in `lib/` calls `ProtoRune.Bsky` or `Repo.create_record/3`. Compensates for proto_rune 0.5.3 validating through Peri schemas that drop `embed` and stamp `langs: ["en"]`; owning the record stays the design even if that is fixed. — `test/kite/write_records_test.exs`; `test/kite/telos_test.exs` "Kite builds the records it posts".
- 2026-09-11 · retired 2026-10-07 (#22) · `Kite.ATProto.DSL` was proto_rune 0.5.3's XRPC DSL with the NSID resolved at call time. On proto_rune 0.6 it is deleted: `AtMcp.ATProto` declares each endpoint as a plain function over proto_rune's `Query`, `Procedure`, `Session` and `Client`. Two gaps remain and are commented there: proto_rune's `defquery` compiles the NSID in, and `ProtoRune.XRPC.query/5` and `procedure/5` take no per-request headers, which the `atproto-proxy` header needs. The second retires if proto_rune takes a `:headers` option. — `AtMcp.ATProto`; `test/at_mcp/network_test.exs` "a read asks for the configured namespace's method, not app.bsky's"; `test/at_mcp/external_account_test.exs`.
- 2026-09-11 · settled · Every read goes through `Kite.ATProto` with the session's own service URL, so no read reaches Bluesky by default for an account hosted elsewhere. Compensates for proto_rune's fixed `default_base_url` and `plc.directory` resolver. — `test/kite/network_test.exs` "every declared read and write carries the namespace"; `test/kite/pds_test.exs`.
- 2026-09-12 · settled · An optional filled in with nothing is absent from the record, not a value. — `test/kite/write_records_test.exs` "an optional filled in with nothing is absent, not a value".
- 2026-10-10 · provisional · A read made of posts (notifications, feeds, searches, `get_posts`, `get_thread`, `get_thread_chain`) returns its summary twice: whole as `structuredContent`, and as text for a model in `content`, written by `AtMcp.Reading` from that structured content alone. The text names each post's author, time, reply target, text, attachments and the uri and cid the write tools take; it leaves out `null`s, DIDs, `raw_text`, facets, URLs and the records of likes, reposts and follows. Every other result's text stays its JSON. The specification says a tool with structured content SHOULD also return the serialized JSON "for backwards compatibility", while its own examples pair readable text with structured results; SEP-2200, which would have made `content` the model's representation, was accepted and then deferred by the core maintainers in May 2026 in favour of results that are one or the other. Retires or becomes settled when that lands. — `AtMcp.Reading`; `test/at_mcp/reading_test.exs`; `test/at_mcp/mcp_tools_test.exs` "every tool returns structured content its declared schema accepts".
- 2026-09-08 · settled · Each record shape an agent receives is declared once in `Kite.Summary`; the backend builds with it and the MCP output schemas derive from it, so a tool cannot describe a field it does not return. — `test/kite/summary_test.exs` "each shape reads exactly the fields it describes"; `test/kite/stdio_test.exs` compares declared tools to tools carrying a schema.
- 2026-09-07 · settled · Batch reads name their own limits locally: pages 1–50, `get_posts`, `get_profiles` and `get_relationships` 1–25 (what both lexicons declare). An oversized list fails before any request. — `test/kite/mcp_read_pages_test.exs` "invalid page sizes fail before the backend is called".
- 2026-09-24 · settled · Each limit is declared once, in the module that enforces it, and everything that states it derives from there: the page and batch limits in `Kite.Effects` (tool schemas, descriptions and error text through `Kite.MCP.Schemas`), the thread shape in `Kite.Effects.ProtoRune`, the endpoint's timeout from the account's own answer time. — `test/kite/limits_test.exs`.

## Writes

- 2026-09-29 · settled · Only publishing counts against the write quota: `post`, `reply` and `repost` reserve, and every other write (like, unlike, unrepost, follow, unfollow, block, unblock, mute, unmute, delete_post, update_profile, update_seen) goes out without spending one. The classification is `Kite.Effects.publishing/0`; each write tool's `kite/publicWrite` annotation and the sentence its description ends with derive from it. · The quota exists to bound what an account publishes; on the first night of the model residents, likes and follows spent it within minutes and real replies waited for the reset (owner, GroveResearch/kite#99). — `test/kite/publishing_quota_test.exs`.
- 2026-09-06 · settled · One durable write quota per DID, 16 attempted writes per hour by default (publishing writes only since 2026-09-29), shared by every client, alias and reconnect; attempted writes count even when the remote call fails; reads do not. — `Kite.WriteQuota`; `test/kite/write_quota_test.exs` "remote failure counts against the write quota"; `test/kite/shared_http_quota_test.exs`.
- 2026-09-06 · settled · The write quota ledger is monotonic (`max(clock, last_seen)`): a clock moved back cannot open a fresh window; a clock moved forward keeps the window closed until real time catches up. That is the safe direction and not worth machinery. — `Kite.WriteQuota` moduledoc; `test/kite/write_quota_test.exs` (clock fixture, "damaged quota state never resets an account's write quota").
- 2026-09-08 · settled · Kite's failure vocabulary has four kinds. A backend reports one of three — refused, credential refused, indeterminate — and any reason of another shape is indeterminate; the fourth, unreadable, says the service answered and Kite's own parsing, in `Kite.Effects.ProtoRune`, could not read the answer; it is never a classification of a service's error. Only the backend classifies its service's errors: nothing above it reads status codes or error text. — `Kite.Effects.Failure`; `test/kite/backend_failure_test.exs`.
- 2026-09-07 · settled · An indeterminate write outcome is reported as `outcome: "unknown"` and never retried; only a refused credential (the backend's `:auth_refused`, for reads as for writes) triggers session recovery, and the retry after recovery takes its own write quota slot. — `test/kite/write_outcome_test.exs` "committed writes with lost responses stay unknown without retry or leaking backend details".
- 2026-09-24 · settled · Every account call answers within `Kite.Effects.call_deadline/0`, which is shorter than the MCP endpoint's handler timeout, so no tool call ends in ExMCP's bare `handler_timeout`: at the deadline a write that reached the backend is `write_outcome_unknown`, and anything else — including a write still waiting behind another — is `call_deadline_exceeded`, which changed nothing. A task marks a write as sent before its last deadline check, so a caller that gives up without the mark is never followed by the write; a write stopped at that check is reported unknown, the safe side. The longest a caller waits is `Kite.Effects.answered_within/0` (the deadline plus twice the owner's grace), and the endpoint's timeout is derived from it. — `test/kite/call_deadline_test.exs`; `test/kite/account_owner_test.exs` "a write queued behind a slow write is not sent once its deadline passes", "a caller that stops waiting is never told nothing changed about a write that is then sent", "the longest a caller waits is answered_within/0, and the endpoint waits longer".
- 2026-09-24 · settled · One process owns each configured account (`Kite.Effects`; not yet each DID, see the provisional line below) and does its work in tasks linked to it: writes one at a time in the order they arrive, reads alongside them without waiting, and one login or refresh at a time whose result every waiting call shares. There is no lock a caller waits on. When the owner stops, its work stops: a write already sent is `write_outcome_unknown`, a call not yet started is `account_runtime_unavailable`. — `test/kite/account_owner_test.exs`; `test/kite/session_ownership_test.exs`; `test/kite/call_deadline_test.exs` "a reply is sent while a read the service never answers is still waiting".
- 2026-09-24 · provisional · The owner is per configured account, not per DID. Two account entries that log in to the same DID (aliases) each have their own `Kite.Effects`, so writes to that DID through different aliases run in parallel and in no order relative to each other, `update_seen` included (two aliases marking notifications seen can finish out of order); within one alias they are ordered. The write quota is already per DID and bounds them together. Retires when one owner serves every alias of a DID, which needs the lifecycle owner to bind an alias to the DID's owner once its login reveals the DID. — unverified (no test names the limit).
- 2026-09-07 · settled · Post and reply text is validated against the network's grapheme and byte limits before the write quota is charged. — `test/kite/post_validation_test.exs`; `test/kite/network_test.exs` "bound what Kite refuses before it sends anything".
- 2026-09-28 · settled · A follow, block, mute or unmute resolves its handle to a DID before the write quota is charged and before anything is sent, so a handle the service could not resolve (a 400 `HandleNotFound` or `InvalidRequest`, or a string that is not a handle) is refused as `handle_not_resolved` at no cost, never reported as an unknown outcome; any other failure of that read keeps its own kind and also costs nothing. Every tool that takes a handle drops one leading `@`. — `Kite.Effects.Backend.resolve_actor/2`; `test/kite/actor_resolution_test.exs`.
- 2026-09-07 · settled · Rich text preserves the supplied text and adds facets for resolved `@handle` mentions, explicit HTTP(S) links and hashtags; an unresolved handle stays plain and is named in `unresolved_mentions`; bare domains and cashtags are not detected. — `test/kite/rich_text_test.exs`.
- 2026-09-07 · settled · Summaries keep the viewer's like/repost/follow/block record URIs so an agent can undo without the original receipt; a missing viewer object is distinct from an empty one. — `test/kite/summary_test.exs` "an absent viewer stays absent and an empty one stays empty"; `test/kite/viewer_context_test.exs`.
- 2026-10-18 · provisional (#69) · Summaries also keep the viewer states the service reports without a record of the account's: `blocked_by`, `muted`, `blocking_by_list`, `muted_by_list`, `followed_by` on a profile and on a post's `author_viewer`, and `reply_disabled`, `embedding_disabled` and `thread_muted` on a post; one the service did not mention is omitted, not `false`. A post's readable text states a block, a mute, a muted thread or a closed reply or quote only when present. — `test/at_mcp/summary_test.exs` "block and mute state is kept as the service sent it, for a profile and a post's author"; `test/at_mcp/reading_test.exs` "a block or mute with the author is said in words; following is not".

## Collection and delivery

- 2026-09-13 · settled · Account notification polling is the ordinary collector (60 s interval, five-minute overlap, private per-DID checkpoint that advances only after a complete retained scan, never marks anything read); the first poll looks back five minutes, not the whole inbox. — `Kite.Notifications`; `test/kite/notifications_test.exs`; `test/kite/collection_config_test.exs`.
- 2026-09-06 · settled · Jetstream is opt-in (`KITE_JETSTREAM=1`), subscribes with an empty `wanted_dids` and matches recipients locally, because incoming interactions are records in somebody else's repository. — `Kite.Stream.Jetstream`; `test/kite/stream_jetstream_test.exs` "an inbox cannot be a DID filter"; `test/kite/inbound_match_test.exs`.
- 2026-09-09 · settled · Collection sits behind `Kite.Stream`; an adapter owns its service's options, message shape and event fields. Jetstream v2 is a new adapter, not a change to collection, matching, the store or delivery. — `Kite.Stream`; `test/support/fake_stream.ex`.
- 2026-09-13 · settled · Delivery is a consumer-neutral HTTP POST of the normalized event to `KITE_DELIVERY_URL` with a bearer token re-read per delivery; the event names its network. Kite supplies no route, character or conversation. — `Kite.Deliver.HTTPBridge`; `test/kite/delivery_consumer_test.exs`; `test/kite/http_bridge_test.exs` "a rotated access token reaches the next delivery".
- 2026-09-13 · retired 2026-09-15 · A `HAVEN_INBOUND_URL` still set at startup refused with `delivery_configuration_migration_required`. The check and the Haven bridge script were deleted once no installation carried the variable; nothing in `lib/` reads it. — `1348897`.
- 2026-09-06 · settled · Delivery is at least once and only a 2xx from the consumer is acceptance; redirects are not followed; the outbox owns retries; the consumer deduplicates by recipient DID plus source URI. — `test/kite/delivery_consumer_test.exs` "a redirected endpoint is not durable acceptance"; `test/kite/inbound_store_test.exs`.
- 2026-09-24 · settled · Delivery is ordered per account and parallel across accounts: each recipient DID is a lane that delivers in acceptance order, one event at a time, and holds later events while an earlier one is in flight or backing off; lanes run concurrently, so one account's slow or failing consumer never delays another's. The acceptance order is persisted; a state file without one loads in key order. — `Kite.Inbound.Store`; `test/kite/inbound_store_test.exs` "a consumer that blocks one account does not hold another account's delivery", "one account's events arrive in acceptance order, and a retry holds the ones behind it", "an account's delivery order survives a store restart", "a state file written before acceptance order was recorded loads and delivers everything", "disconnect stops only the disconnected account's delivery; terminate stops the rest".
- 2026-09-24 · settled · Pending events are never evicted: capacity pressure holds collection until delivery makes room. Capacity is per account lane (10,000 events, 16 MiB of events) so a stuck account refuses only batches that add to it; the whole file keeps a 64 MiB ceiling and 10,000 receipts. A notification sweep covers one account; Jetstream's shared cursor pauses the stream for every account while one lane is full. — `Kite.Inbound.Store`; `test/kite/inbound_store_test.exs` "capacity never acknowledges overflow", "a full account refuses only batches that add to it", "an account full by bytes refuses only its own events".
- 2026-09-24 · settled · The notification poller accepts a completed sweep oldest first by `indexed_at`, in page-sized chunks, before its checkpoint advances; an incomplete sweep accepts nothing. — `Kite.Notifications`; `test/kite/notifications_test.exs` "a sweep's notifications are delivered oldest first across its pages", "a sweep larger than the account's room accepts its oldest events and holds the checkpoint".
- 2026-09-13 · settled · A configured delivery bridge with no collector enabled fails closed at startup; with only the notification poll it is live. — `test/kite/http_bridge_test.exs` "maybe_attach_from_env! fails closed when no collector is enabled".

- 2026-09-24 · settled (owner) · An incomplete notification sweep accepts nothing, so each account's events enter delivery in order; the cost, an account whose delivery stalls behind one refused event, is made visible: `kite-accounts status` reports the account's delivery stalled with the consumer's last answer and since when, kept with the event across restarts. — `test/kite/accounts_test.exs` "status shows an account whose delivery is stalled, why, and since when".

## State and process

- 2026-09-06 · settled · One process owns each state directory, and the lock survives a supervisor restarting the process that took it. — `Kite.StateLock`; `test/kite/state_lock_test.exs` "cold BEAMs preserve exclusive durable ownership through crashes and application restarts".
- 2026-09-19 · settled · The inbound checkpoint is trusted input: Kite wrote it, mode 0600 inside its own 0700 state directory, so it is decoded without `[:safe]` and the structural checks that follow are what reject a damaged one. `[:safe]` guards against atom exhaustion from a hostile peer, and here its failure mode was refusing to start — an event key belonging to whichever collector produced it (`:thread_root_uri`) does not exist in a fresh VM, so a restart with any pending event killed the application. — `Kite.Inbound.Store.load!/1`; `test/kite/inbound_store_test.exs` "a cold VM restores a pending event whose keys no loaded module defines".
- 2026-09-07 · settled · Damaged state (quota, checkpoint, outbox, accounts) fails startup rather than resetting policy. — `test/kite/write_quota_test.exs` "damaged quota state never resets an account's write quota"; `test/kite/notification_store_test.exs`; `test/kite/inbound_store_test.exs` "damaged state fails startup instead of silently losing the queue"; `test/kite/account_config_test.exs` "corrupt config preserved".
- 2026-09-07 · settled · Disconnect is persisted before the account's processes stop and fences every alias of the DID; reconnect is explicit; a failed login leaves the account disconnected; removing its configuration keeps history and the disconnected state. Neither revokes the app password at the provider. — `test/kite/accounts_test.exs`; `test/kite/account_control_test.exs`.
- 2026-09-07 · settled · Reload validates the whole plan first, leaves unchanged owners running and replaces only affected ones; a replacement that cannot log in stays unavailable and reload exits nonzero; stale credentials never become the fallback. — `test/kite/accounts_reload_test.exs`.
- 2026-09-07 · settled · Programmatic identities keep their configuration only in the running lifecycle owner; after a restart they report `configuration_required` until supplied again, and their disconnected state stays durable. — `test/kite/accounts_test.exs` "durable disable survives store and lifecycle owner restart; dynamic accounts must be configured again".
- 2026-09-06 · settled · Embedding sets `boot_from_env: false` and `start_mcp: false`; Kite then creates no environment accounts, inherits no host credentials and opens no port. — `test/kite/embedding_test.exs`.

## Dependencies

- 2026-10-08 · settled (#13) · `get_relationships` resolves each handle in `others` to a DID before the AppView call, because that service resolves only `actor` and keys `others` by DID; a handle that does not resolve is `not_found` and names the handle. Retires when the AppView resolves `others` the same way. — `AtMcp.Effects.ProtoRune.get_relationships/3`; `test/at_mcp/actor_resolution_test.exs` "get_relationships resolves a handle in others and asks about the DID".

- 2026-10-02 · settled (owner, #106) · `ex_mcp` comes unmodified from Hex. Kite keeps each output schema beside its tool; `Kite.MCP.DSL` advertises it and `Kite.MCP.Output` validates the completed response. A timeout or schema defect logs a diagnostic without replacing the action's result: validation cannot undo a write. Schema-contract tests explicitly reject mismatches. This removes Kite's need for the private output-validation patch; other consumers keep their own documented dependencies. The adapter retires when upstream supports this post-action policy. — `test/kite/write_outcome_test.exs`; `test/kite/mcp_tools_test.exs`; `test/kite/stdio_test.exs`.

- 2026-10-02 · settled · `Kite.NativeLock` owns the native advisory lock formerly vendored from flock_ex 0.1.0, built with released Hex `elixir_make`. Resource-lifetime ownership, long paths, private close-on-exec descriptors and serialized cleanup remain required; state and private-file callers use one implementation. Derived code keeps Apache-2.0 attribution in `licenses/flock_ex`, included in releases. — `test/kite/native_lock_test.exs`; `test/kite/state_lock_test.exs`; `test/kite/account_edit_test.exs`.

- 2026-09-13 · settled · ProtoRune's own retry loop is off (`config :proto_rune, :retry, false`); Req owns retries. Retires when ProtoRune stops nesting a 429 loop around Req. — `config/config.exs`; `test/kite/http_retry_test.exs`.
- 2026-09-11 · settled · Three XRPC declarations exist because proto_rune's are wrong (`getPostThread` `parentHeight` casing, `getRelationShips` spelling, `updateSeen` sent a `DateTime`), and list parameters build their own query because `URI.encode_query/1` cannot. Each retires when the library's declaration is corrected, which a version bump alone does not do. — `Kite.ATProto`; `test/kite/effects_test.exs` "updateSeen is declared with a string wire type", "flatten_query repeats list keys".
- 2026-09-11 · settled · The build must not depend on the operator's environment: `config/runtime.exs` reads `KITE_*` at boot and the release and macos jobs fail if any is set at build time. — `.github/workflows/check.yml`.

## Working method

- 2026-09-11 · settled (owner) · The suite is the evidence; verification logs and dated exercise narratives are retired. What was exercised on a day goes in the pull request. — `bb04958`; workshop `AGENTS.md`.
- 2026-09-11 · settled (owner) · Tests falsify: revert the change and watch the named test fail before believing it. — workshop `AGENTS.md`.
- 2026-09-11 · settled · Kite's purpose is written as claims in `test/kite/telos_test.exs`; a claim not yet true is tagged `:goal` and excluded, and making it true deletes the tag in the same commit. No claim is tagged today. — `test/test_helper.exs`.
- 2026-09-15 · settled · Continuous integration runs as a stranger would: fresh checkout, `mix deps.get`, the official MCP client packages, a production release, and `mix check` with every environment-gated test's environment supplied, failing if any test skipped. — `.github/workflows/check.yml`.
- 2026-09-11 · retired 2026-09-29 · Releases are built per target in CI on main, tags and request only, because macOS runner minutes on a private repository cost ten times Linux's. Since 2026-09-25 every build of main is published as GitHub release `vBUILD` (version and short commit, from the release's own `BUILD` file) with each tarball and its `.sha256`, marked latest, after the macOS leg has run it under the example launchd plist, upgraded to from the newest published release and rolled back; a requested run on a branch publishes nothing. — `.github/workflows/release.yml`; `mix.exs` `write_build/1`. Retired: a release is published only when a version tag is pushed (below).
- 2026-09-29 · settled (owner) · A release is published when a version tag is pushed, never on a merge: deploying is a deliberate act (GroveResearch/kite#96). A release is its version: bump `version:` in `mix.exs`, merge, and push the tag `v` + that version; the build jobs refuse any other tag, and tarballs are named by version. One workflow: `test` and `release` (the Linux release, its boot check, upgrade and rollback from the newest published release under the example systemd unit, the tarball owner check) run on pull requests, main and tags; `macos` (the same under the example launchd plist) runs on a tag or on request; on a tag, one `publish` job creates the release with the tarball of each build job that passed, so the Linux one a server needs never waits on macOS, and the notes say which platforms it has. Release N of an upgrade check is the newest published release carrying that platform's tarball whose tag is not the commit under test. The release's `BUILD` file still names its commit, and an N built from the commit under test is refused. — `.github/workflows/check.yml`; `scripts/published_release.py`; `mix.exs` `write_build/1`.
- 2026-09-11 · settled (owner) · No issue tracker and no ticket file, log or diary in the repository; work is a pull request on a topic branch. — workshop `AGENTS.md`.
- 2026-09-24 · settled (owner) · Nothing goes upstream unless it has to: no issues or messages to maintainers; a pull request upstream only for a change that cannot be made from outside the library, as one minimal change with a test that fails without it, seen by the owner before it is sent. Supersedes "nothing goes to upstream maintainers" (09-11). — workshop `AGENTS.md` standing decisions.