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-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 · settled · `Kite.ATProto.DSL` is proto_rune's XRPC DSL with the NSID resolved at call time. Compensates for the library compiling the namespace in; retires if proto_rune takes a namespace. — `Kite.ATProto.DSL`; `test/kite/network_test.exs` "a read asks for the configured namespace's method, not app.bsky's".
- 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-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`.

## 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-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.