Context and Problem Statement
Team Kitty's server side has been frozen for development while the SaaS and Zeitgeist relay
mature. Against that freeze, the CLI still shipped a hard-coded live default
(https://team.spec-kitty.ai, #3980) and automatically fanned status, lifecycle, runtime, and
decision moments out to it whenever an endpoint resolved — with no per-developer or per-repository
consent step. On 2026-09-23 the operator directed that the shipped build stop targeting a live
hosted server on its own: nothing should leave a fresh install's machine unless the developer has
explicitly opted in.
There is a gap in the record this ADR closes first. Mission team-kitty-launch-defaults-01M1XJ4Y
WP01 planned the ADR that would have recorded the original packaged-default decision
(2026-09-07-1-packaged-hosted-target-default.md), but that file was never written
(research.md R4 for this mission). Reversing an undocumented decision has no prior record to
amend, so this ADR is the sole governance record covering both the original packaged-default
decision this mission reverses and the new two-scope drain consent model that replaces it.
Two further constraints shaped the design:
- The committed lane ledger (
status.events.jsonl) and decision ledger (decisions/) are a non-optional floor ("Git carries DONE", #4311) — no opt-in flag in this mission may be able to stop them being written or committed, only the automatic outbound hosted side effects. - A future bounded live-publish retry for #4311 is anticipated but not yet on
main; whatever gate this mission adds must also bind that future edge, not just today's callers (C-005).
Decision
Reverse the packaged default and replace it with a two-scope, explicit opt-in ("drain") plus a narrower, unrelated flag for the local execution-state projection ("ledger"). Recorded verbatim in spirit from the mission's Operator decisions table and its post-spec squad resolutions:
- D-1 — Drain lives in two places, both required.
.kittify/config.yaml→hosted.drain(repository opt-in) and the developer's runtime-rootconfig.toml→[hosted] drain(personal activation). Effective drain requires both on; either off or absent is off. Managed byspec-kitty moments drain on|off|status [--repo]. - D-2 / FR-010 — The ledger flag gates only the derived projection.
.kittify/config.yaml→ledger.projection(defaulttrue) gates automatic refresh of the gitignored, derived, always-rebuildable local execution-state projection under.kittify/derived/<mission>/— never the lane ledger, its commit, the committed status snapshot, the decision ledger, the run journal, or invocation records. The two flags are orthogonal axes: one gates outbound network side effects, the other gates a purely local, re-derivable read model. - D-3 — Explicit vs automatic guidance split. An explicit hosted command run with no configured
endpoint stops with setup guidance naming
SPEC_KITTY_SAAS_URLandconfig.toml [sync].server_url, no traceback (HostedEndpointUnconfigured) — most such commands exit non-zero, but a status-reporting command (auth status) exits 0 with the same guidance, since reporting "not configured" is itself a successful answer. An automatic path (a lane transition, a lifecycle beat) resolves the endpoint viaresolve_server_target_or_none()and stays silent when none is configured. - FR-004 — Widen and interview startup skip their hosted prereq probe when drain is off. (Not
independently lettered in the Operator decisions table; folded into FR-004's "every automatic
hosted egress" requirement, not to be confused with plan.md's unrelated "D7" personal-writer
strictness label.)
widen/prereq.py::check_prereqsand the charter/specify/plan interview startups (cli/commands/charter/interview.py,missions/plan/plan_interview.py,missions/plan/specify_interview.py) each checkhosted_posture.drain_posture().enabledbefore making any call against the injected SaaS client — with drain off, the automatic hosted prereq probe these startups would otherwise run is skipped outright, consistent with D-3's "automatic paths stay silent" rule rather than surfacing a probe failure. - D-4 / C-006 — Keep
[sync].server_url's name. Its rename is a separate, out-of-scope follow-up; this mission does not touch the key name. - D-5 — The personal activation file is the runtime-root
config.toml. Resolved byspecify_cli.paths.get_runtime_root()—~/.spec-kitty/config.tomlon POSIX,SPEC_KITTY_HOME-overridable — the same file that already carries[sync].server_url. This is not~/.kittify/config.toml, where[moments]already lives; the two are deliberately different files, andmoments drain statusprints the resolved absolute path of every file it reads (including this one) so the distinction is never silent. - D-6 / FR-016 — Logged-in machines keep their endpoint. A machine with a stored auth session
from before the packaged default was removed has that session's
issuer_urlbackfilled into[sync].server_urlby an upgrade migration when no endpoint is configured — read-only against the session store, never for a retired first-party host, never overwriting an explicit value, and never enabling drain on its own. A configured endpoint and an enabled drain are independent facts. - R-1 — No environment variable can enable drain, in either scope. The only env vars
hosted_posture.drain_posture()itself consults as narrowers are the three folded intomoment_handlers_disabled_reason()—SPEC_KITTY_NO_MOMENT_HANDLERS,SPEC_KITTY_SYNC_DISABLE, and the deprecated aliasSPEC_KITTY_SYNC_MINIMAL_IMPORT— any of which forces the effective posture off even with both scopes on.[moments] agents = "off"is a separate, non-env-var axis: the global or per-repo[moments]config key (~/.kittify/config.tomlor<repo>/.kittify/config.toml) that narrows which moments reach agent context.DrainPostureitself does not read it — it is tracked independently, alongside drain's own narrowers, bymoments drain status(_drain_narrowersincli/commands/moments.py) as a second axis that can also block a moment even when drain is fully on. A per-repo.kittify/config.tomlnever carries the personal drain activation either — only the runtime-rootconfig.tomldoes (D-5). - R-2 — Explicit endpoint configuration is not drain-enablement.
.kittify/saas-auth.json(its own token) and aSPEC_KITTY_SAAS_URLsourced from a committed.kitty.envboth count as explicit operator configuration of where hosted traffic would go, never as consent for it to be sent automatically. - R-3 — Drain gates all relay traffic, not auth or the tracker. Every relay CLI command and MCP
relay tool — including operator-typed commands such as
zeitgeist send/reply/outbox approve— pre-flightsrequire_drain("relay")before any credential read.auth login/logout/status/ whoami/doctorand tracker commands are gated only by whether an endpoint is configured, not by drain. - R-4 / FR-013 — The retired-target migration now deletes, it no longer rewrites (#4259).
m_4_0_0_retired_hosted_targetpreviously rewrote a savedconfig.toml [sync].server_urlnaming the retired first-party address (https://app.spec-kitty.ai) to the packaged default; endpoint opt-in (D-1..D-5, reversing #3980) removed that packaged default from the resolver entirely, so rewriting to a live address here would silently point a machine that never configured a hosted endpoint at one. The migration'sapply()now deletes the stale key instead and prints the sameSPEC_KITTY_SAAS_URL/config.toml [sync].server_urlsetup guidance an unconfigured explicit command would show — every other saved value is untouched. A machine that already hasteam.spec-kitty.airecorded from an earlier run of this same migration (before this behavior change) is a distinct, explicitly-preserved case: it keeps that value as explicit, opt-in configuration. Drain still defaults off in both cases, so no automatic traffic follows from a configured endpoint alone.
The operator surface for the whole model is spec-kitty moments drain on|off|status [--repo] [--json], which reads and writes exclusively through core.hosted_posture (never a second
config reader or writer) and reports the effective posture, both scopes' values and source files,
active narrowers, the ledger-projection posture, and all four hosted-posture file paths — existing
or not.
Consequences
- A fresh install talks to no server. With no endpoint configured and drain off by default,
neither
SPEC_KITTY_SAAS_URLnorconfig.toml [sync].server_urlneed to be set for local work — specify, plan, tasks, implement, lane transitions, decisions — to complete unaffected; the committed lane and decision ledgers are unconditionally intact under every setting (FR-010). "Zero network" here means no*.spec-kitty.ai/ Team Kitty / Zeitgeist host is contacted; the PyPI upgrade-check notice is explicitly out of this scope — it already has its own opt-out,SPEC_KITTY_NO_UPGRADE_CHECK(C-007). - The #4311 coupling is closed by construction, not by a future patch (C-005). #4311's bounded
live-publish retry is not yet on
main; because the drain gate sits at the network edge (transport.ZeitgeistClient.offer, the capability gateway, the relay CLI/MCP surfaces) rather than at each call site that might someday retry, any future retry path inherits the same drain-off clean-skip behavior automatically. A drain refusal is never a diagnostic-as-error and is never retried. - Two independent opt-ins replace one implicit default. An operator who wants live drain must turn on both the repository scope and their own personal scope — there is no single switch, and no environment variable can shortcut either one (R-1). This is a deliberate friction increase over the pre-#3980-reversal behavior, in service of the "twice, explicitly" requirement in #4971's brief.
- Forward references, not relitigated here: renaming
[sync].server_url(D-4) stays a separate follow-up issue; retiring theSPEC_KITTY_ENABLE_SAAS_SYNCresidue is likewise deferred except where a future change happens to touch the same lines. This ADR records only what this mission shipped.