Contracts

acceptance.md

Contract: acceptance evidence

Every row is red-first on the merge-base through the named entry point and green at head. Blast radius per CLAUDE.md §Test policy plus tests/architectural/ in full (cross-cutting env/config change).

IDScenarioEntry pointEvidence
A1fresh install, no config, no session, interactive statuspublic spec-kitty subprocess, isolated SPEC_KITTY_HOME, ptycompletes; one hint on stderr; second run: no hint; JSON run: no hint
A2auth login with nothing configuredsubprocess against a recording stub OAuth serverrequest lands on the packaged default host; target line printed with packaged default
A3target matrix (contracts/target-resolution.md)resolve_server_target unit + auth status subprocessevery cell resolves and prints the expected source; split-brain still fails closed
A4owned checkout lane movereal repo + --owned-checkout + recording relay stubexactly one event.publish; attrs identical to worktree case
A5unauthenticated full cyclerecording stub for SaaS + relay0 requests across specify → review incl. readiness
A6auth logout then lane movesame stub0 requests after logout; hint marker reset
A7tracker command / --from-ticket logged outsubprocessexit non-zero with sign-in guidance; no "not enabled" text anywhere in the tree
A8retired names present in env and configsubprocessno effect; [sync] table ignored
A9opt-outsunitpre-review gate skipped only by its own name; handler registration suppressed only by its own name
A10vocabularygit grep over live src/docs/templateszero occurrences of retired identifiers outside the occurrence-map exceptions
A11gatestests/architectural/ fullat or below baselines; terminology guard green

auth-visibility.md

Contract: target visibility in auth login / auth status

Both commands print, before any network activity, one line of the form:

Team Kitty target: https://team.spec-kitty.ai (packaged default)
Team Kitty target: https://spec-kitty-dev.fly.dev (config.toml [team_kitty] server_url)
Team Kitty target: https://x.example (SPEC_KITTY_SAAS_URL)
  • auth status and auth whoami reuse _auth_saas_target.print_saas_target; auth login calls the same printer.
  • The existing issuer-mismatch warning is unchanged.
  • --json variants carry target.url and target.source fields; no token or session value appears in any output (NFR-004).
  • The retired "Set SPEC_KITTY_SAAS_URL … and try again" remediation text is removed; the only remaining remediation is the split-brain message.

environment-and-config.md

Contract: environment variables and configuration after this mission

NameStatusMeaning
SPEC_KITTY_SAAS_URLkeptenvironment override of the hosted target
SPEC_KITTY_SAAS_TOKENkeptservice-token bearer (counts as authenticated)
SPEC_KITTY_TEAM_SLUGkeptscope selector
SPEC_KITTY_SAAS_FANOUT_TIMEOUTkeptfan-out bound
SPEC_KITTY_SKIP_PRE_REVIEW_GATEnewskip the pre-review regression gate
SPEC_KITTY_NO_MOMENT_HANDLERSnewregister no moment handlers at import
SPEC_KITTY_ENABLE_SAAS_SYNCremovedunknown to the CLI; no effect, no notice
SPEC_KITTY_SYNC_DISABLEremovedunknown; no effect
SPEC_KITTY_SYNC_MINIMAL_IMPORTremovedunknown; no effect
config.toml [team_kitty] server_urlnewconfigured hosted target
config.toml [sync] server_urlremovednever read
  • core/env.py exposes two single-purpose accessors for the new names; the SYNC_DISABLE_ENV_VARS tuple and first_set_sync_disable_env are deleted.
  • core/secret_redaction.py printable allowlist and m_3_2_8_provision_kitty_env.py::GOVERNED_OPERATOR_VARS list the new names and drop the retired ones; the migration never seeds a retired name again and never writes a value it did not observe.
  • docs/api/environment-variables.md is the user-facing source for this table.

moment-path.md

Contract: moment publishing under launch defaults

Unchanged wire behavior (docs/context/team-kitty.md), restated as the acceptance contract:

1. Every lane transition and lifecycle beat calls fire_saas_fanout / fire_lifecycle_saas_fanout, from lane worktrees and owned checkouts alike. OWNED_SYNC_UNSUPPORTED no longer exists. 2. With AuthenticationState = unauthenticated, credential resolution returns None before any request; a recording stub observes 0 requests across specify → plan → tasks → implement → review, including readiness. 3. With a resolvable capability, exactly one POST <relay>/managed/control (op: event.publish) per transition, 750 ms budget, no retry; failures are logged at debug and never surface to the user. 4. An owned-checkout transition produces a moment byte-identical in kind, ref, and attrs to the same transition from a lane worktree (only session_id differs). 5. SPEC_KITTY_NO_MOMENT_HANDLERS=1 registers no handlers at import; it is the only way to suppress the fan-out for an authenticated process and is documented as a test/isolation switch, not an operator feature.

saas_rollout.md

Contract: SaaS Rollout Gate

Version: 3 (#3980, Team Kitty launch defaults — the default flipped from opt-in to opt-out; version 2 relocated the canonical home from saas/rollout.py to core/saas_sync_config.py [#2252]) Module: src/specify_cli/core/saas_sync_config.py (canonical home). src/specify_cli/tracker/feature_flags.py re-exports from here (the saas/rollout.py and sync/feature_flags.py shims died with their packages, issue #5). Stability: Internal CLI surface, but public to all of src/specify_cli/. Treat as a stable internal contract.

Provenance: this is the live, self-contained version-3 contract for mission team-kitty-launch-defaults-01M1XJ4Y (#3980). The version-2 record it supersedes lives at kitty-specs/082-stealth-gated-saas-sync-hardening/contracts/saas_rollout.md and stays byte-frozen there — tests/architectural/test_archive_root_byte_identical.py (NFR-002) freezes every pre-existing file under kitty-specs/ at the port base, so a contract version bump is recorded as a NEW file in this mission's dossier, never as an edit to the archived original (same pattern as kitty-specs/dispatch-dry-run-route-only-01M1HKV2/contracts/cli-dispatch-dry-run-output.md).


Functions

is_saas_sync_enabled() -> bool

Inputs: None (reads process environment).

Returns: True unless the environment variable SPEC_KITTY_ENABLE_SAAS_SYNC explicitly opts out (launch default, #3980: on):

  • Unset variable → True
  • Empty / whitespace-only string → True
  • "1", "true", "yes", "y", "on" (case-insensitive) → True (redundant confirmation)
  • "0", "false", "no", "off" → False (the opt-out)
  • Any other non-empty value (e.g. "banana") → False

Side effects: None. Pure function (modulo os.environ read).

Performance: O(1).

sync_active() -> bool

Returns: is_saas_sync_enabled() and not sync_kill_switch_active() — the process-wide kill switch is SPEC_KITTY_SYNC_DISABLE alone. SPEC_KITTY_SYNC_MINIMAL_IMPORT no longer disarms sync; it is a deprecated alias of the moment-handler import gate (core.env.moment_handlers_disabled_reason).

saas_sync_disabled_message() -> str

Inputs: None.

Returns: A human-readable, one-line message explaining the rollout is off and how to re-enable it. Stable wording (asserted by tests):

> Hosted SaaS sync is disabled on this machine. Unset SPEC_KITTY_ENABLE_SAAS_SYNC (or set it to 1) to re-enable it.


NameRole
SPEC_KITTY_ENABLE_SAAS_SYNCopt-out only (=0); absent means on
SPEC_KITTY_SAAS_URLdev/self-host override of the packaged default (DEFAULT_HOSTED_SAAS_URL, auth/config.py)
SPEC_KITTY_SAAS_TOKEN, SPEC_KITTY_TEAM_SLUGservice-token and scope overrides
SPEC_KITTY_SYNC_DISABLEthe single process-wide kill switch (sync_active()), nothing else
SPEC_KITTY_SYNC_MINIMAL_IMPORTdeprecated alias of the moment-handler gate, warns once
SPEC_KITTY_NO_MOMENT_HANDLERSthe moment-handler import gate (status/adapters.py)
SPEC_KITTY_SKIP_PRE_REVIEW_GATEthe pre-review regression gate's own opt-out (agent/tasks_move_task.py)
SPEC_KITTY_SAAS_FANOUT_TIMEOUTunchanged, documented

Post-launch, SPEC_KITTY_SYNC_MINIMAL_IMPORT and the deprecated ENABLE spellings are removed from core/env.py, core/secret_redaction.py, and m_3_2_8_provision_kitty_env.py (#3980 post-launch section).


Backwards Compatibility Shims

The following modules continue to export is_saas_sync_enabled and saas_sync_disabled_message:

  • src/specify_cli/tracker/feature_flags.py

All shims re-export the same function objects (identity preserved) and add no behavior. Any future change to the env-var contract is made once in core/saas_sync_config.py.


Usage Contract for Callers

1. CLI Typer registration (src/specify_cli/cli/commands/__init__.py):

is enforced at invocation time by tracker.py's callback (tracker_callback / per-command checks), which print saas_sync_disabled_message() and exit 1 when the gate is off.

  • Registration of the tracker group is unconditional; the rollout gate

2. Programmatic callers (dashboard, sync events, readiness):

  • MUST call is_saas_sync_enabled() directly OR rely on evaluate_readiness() (which checks rollout first).
  • MUST NOT cache the result across process boundaries — the env var is read each call by design.

3. Tests:

disabled state (deleting the variable no longer disables it — the default is on) / monkeypatch.setenv(..., "1") for the enabled state.

collection-wide authority (os.environ.setdefault("SPEC_KITTY_ENABLE_SAAS_SYNC", "1"), #3213) — the suite never depends on the packaged default or on runner ambient state, and a producer must NOT pre-set the flag to 0 in the pytest environment (that would suppress the collection-time authority and skip the import-time gated tests). The CI workflows that invoke the CLI itself outside pytest pin their own opt-out instead (e.g. .github/workflows/ci-windows.yml). The former monolithic .github/workflows/ci.yml ambient pin was retired with that workflow's modular replacement on main.

  • Use monkeypatch.setenv("SPEC_KITTY_ENABLE_SAAS_SYNC", "0") to simulate the
  • The autouse fixture at tests/conftest.py sets the gate ON by default; dual-mode tests opt out explicitly.
  • The suite's own environment is pinned by tests/conftest.py's

Test Requirements

NOT disarmed by SPEC_KITTY_SYNC_MINIMAL_IMPORT alone

  • The rollout-gate test file MUST cover at minimum:
  • Unset env var → True
  • Empty string → True
  • "1" / "true" / "TRUE" / "yes" / "on" → True
  • "0" / "false" / "off" → False
  • Garbage values ("banana") → False
  • sync_active() is False when SPEC_KITTY_SYNC_DISABLE is truthy, and is
  • The disabled message wording is byte-for-byte stable
  • mypy --strict clean

target-resolution.md

Contract: hosted target resolution

Authority: specify_cli.auth.server_target.resolve_server_target (single).

env SPEC_KITTY_SAAS_URLconfig.toml [team_kitty] server_urlResultsource
unsetunsethttps://team.spec-kitty.aipackaged_default
unsethttps://spec-kitty-dev.fly.devdev hostconfiguration
https://xunsethttps://xenvironment
https://xhttps://xhttps://xenvironment (override mode NONE)
https://xhttps://yfail closed: ServerTargetSplitBrain naming both—
unsetmalformed/blankpackaged defaultpackaged_default
  • The [sync] table is never read. A [sync] table present in a config file is ignored entirely.
  • get_saas_base_url() returns the env value or None; it no longer raises.
  • Every hosted caller (auth login, token refresh, capability mint, admission preflight, tracker readiness, readiness nag) consumes the same resolved target; none may compute its own.
  • MISSING_HOST_CONFIG can no longer occur; the readiness code for it is removed.