Contracts

background_daemon_policy.md

Contract: Background Daemon Policy and Intent Gate

Modules:

  • src/specify_cli/sync/config.py (extended)
  • src/specify_cli/sync/daemon.py (extended)

Stability: Internal CLI surface. The TOML key shape is operator-visible and therefore stable.


Configuration

TOML Key

File: ~/.spec-kitty/config.toml

[sync]
server_url = "https://spec-kitty-dev.fly.dev"
max_queue_size = 100000
background_daemon = "auto"   # "auto" | "manual"
KeyTypeDefaultRequiredDescription
[sync].background_daemon"auto" \"manual""auto"no

Loader behavior (src/specify_cli/sync/config.py):

  • Missing key → default AUTO (preserves current behavior).
  • Empty string → reject with config error during load.
  • Unknown value (e.g., "sometimes") → log a one-line warning to stderr, fall back to AUTO.
  • Case-insensitive parsing ("AUTO", "Manual" accepted).

BackgroundDaemonPolicy Enum

class BackgroundDaemonPolicy(str, Enum):
    AUTO   = "auto"
    MANUAL = "manual"

Intent Gate

DaemonIntent Enum

class DaemonIntent(str, Enum):
    LOCAL_ONLY      = "local_only"
    REMOTE_REQUIRED = "remote_required"

ensure_sync_daemon_running Signature

def ensure_sync_daemon_running(
    *,
    intent: DaemonIntent,
    config: SyncConfig | None = None,
) -> DaemonStartOutcome: ...
  • intent is mandatory and keyword-only. There is no default.
  • config defaults to a freshly loaded SyncConfig if not supplied (matches existing pattern).

Decision Matrix

is_saas_sync_enabled()intentpolicystartedskipped_reason
FalseanyanyFalse"rollout_disabled"
TrueLOCAL_ONLYanyFalse"intent_local_only"
TrueREMOTE_REQUIREDMANUALFalse"policy_manual"
TrueREMOTE_REQUIREDAUTOTrue (on success)None
TrueREMOTE_REQUIREDAUTOFalse"start_failed: <reason>"

DaemonStartOutcome

See data-model.md §8. Frozen dataclass; consumers MUST treat it as a value.


Caller Audit (R-005)

The following are the only call sites permitted to invoke ensure_sync_daemon_running() after this mission. Each is updated to pass an explicit intent:

FileCall site purposeNew intent
src/specify_cli/dashboard/server.pyDashboard process startupLOCAL_ONLY
src/specify_cli/dashboard/handlers/api.py (read endpoints)Returning local snapshot dataLOCAL_ONLY
src/specify_cli/dashboard/handlers/api.py (explicit "sync now" endpoint)User-initiated remote sync from dashboardREMOTE_REQUIRED
src/specify_cli/sync/events.py (event upload path)Upload pending events to SaaSREMOTE_REQUIRED
src/specify_cli/cli/commands/tracker.py (sync pull, sync push, sync run, sync publish)Direct user requestREMOTE_REQUIRED

No other module may call ensure_sync_daemon_running() without being added to this list and the corresponding test in tests/sync/test_daemon_intent_gate.py. A grep test in CI prevents drift.


Manual-Mode CLI Behavior

When policy_manual blocks an otherwise-REMOTE_REQUIRED call from a CLI command:

  • The command exits with code 0 (this is not an error — the operator chose this).
  • It prints to stdout (stable wording, asserted by tests):

> Background sync is in manual mode ([sync].background_daemon = "manual"). > Run spec-kitty sync run to perform a one-shot remote sync.

When policy_manual blocks a dashboard handler call:

  • The handler logs the skip at INFO level with skipped_reason="policy_manual".
  • It does NOT crash the request.
  • The dashboard UI surfaces "manual mode" in its status panel (best-effort; not gated by this mission).

Test Requirements

tests/sync/test_config_background_daemon.py:

  • Missing key → AUTO default.
  • "auto" / "AUTO"AUTO.
  • "manual" / "Manual"MANUAL.
  • "banana" → warning + AUTO.
  • Empty string → config load error.

tests/sync/test_daemon_intent_gate.py:

  • Every row of the decision matrix above is asserted.
  • Mandatory intent= keyword: a TypeError is raised if a caller omits it (regression guard).
  • Audit guard: a directory grep test asserts no other module calls ensure_sync_daemon_running() outside the audit list.
  • Both rollout-on and rollout-off modes exercised.

tests/agent/cli/commands/test_tracker.py (extended):

  • sync run with policy=MANUAL exits 0 and prints the manual-mode message.
  • sync run with policy=AUTO reaches the existing daemon path.

hosted_readiness.md

Contract: Hosted Readiness Evaluator

Module: src/specify_cli/saas/readiness.py Stability: Internal CLI surface, public to all of src/specify_cli/. Stable contract.


Types

  • ReadinessState (Enum) — see data-model.md §2
  • ReadinessResult (frozen dataclass) — see data-model.md §3

Function: evaluate_readiness

def evaluate_readiness(
    *,
    repo_root: Path,
    feature_slug: str | None = None,
    require_mission_binding: bool = False,
    probe_reachability: bool = False,
) -> ReadinessResult: ...

Inputs

ParameterRequiredDefaultDescription
repo_rootyesAbsolute path to the repo root; used to locate auth, config, and bindings.
feature_slugnoNoneActive feature slug. Required when require_mission_binding=True.
require_mission_bindingnoFalseWhen True, an absent mission binding causes MISSING_MISSION_BINDING; otherwise binding is not checked.
probe_reachabilitynoFalseWhen True, issue a single bounded HTTP HEAD against SyncConfig.server_url; otherwise reachability is never checked.

Output

A ReadinessResult. Always one of the six ReadinessState members. The function never raises — internal exceptions are converted into HOST_UNREACHABLE results with the exception type captured in details["error"].

Check Order (short-circuits on first failure)

1. is_saas_sync_enabled()ROLLOUT_DISABLED 2. Auth lookup → MISSING_AUTH 3. specify_cli.auth.config.get_saas_base_url() — if this raises ConfigurationError (because SPEC_KITTY_SAAS_URL is unset or empty) → MISSING_HOST_CONFIG. The returned URL is the value used for reachability in step 4 and for the HOST_UNREACHABLE failure message. This check does not consult SyncConfig.get_server_url() — per decision D-5 in src/specify_cli/auth/config.py, SPEC_KITTY_SAAS_URL is the authoritative host URL surface. 4. (only if probe_reachability=True) HEAD probe against the URL from step 3 → HOST_UNREACHABLE 5. (only if require_mission_binding=True) binding lookup → MISSING_MISSION_BINDING 6. READY

Performance Budget

StepBudget
Steps 1–3, 5 (local I/O only)< 50 ms typical, < 200 ms worst case
Step 4 (reachability)≤ 2,000 ms total (single attempt, no retry)

Failure Message Contract (NFR-002)

For every non-READY state, the result MUST satisfy:

  • message names the prerequisite explicitly (e.g., "No SaaS authentication token is present", not "not ready").
  • next_action provides one concrete actionable step (e.g., "Run spec-kitty auth login").
  • details MAY contain structured context but MUST NOT be the only source of human-readable information.

Stable wording (asserted by tests)

Statemessage templatenext_action template
ROLLOUT_DISABLED"Hosted SaaS sync is not enabled on this machine.""Set SPEC_KITTY_ENABLE_SAAS_SYNC=1 to opt in."
MISSING_AUTH"No SaaS authentication token is present.""Run spec-kitty auth login."
MISSING_HOST_CONFIG"No SaaS host URL is configured.""Set SPEC_KITTY_SAAS_URL in your environment."
HOST_UNREACHABLE"The configured SaaS host did not respond within 2 seconds.""Check network connectivity to {server_url} and retry."
MISSING_MISSION_BINDING"No tracker binding exists for feature {feature_slug}.""Run spec-kitty tracker bind from this repo."

Caller Contract

Tracker CLI commands (src/specify_cli/cli/commands/tracker.py)

  • The current generic _require_enabled() callback is REPLACED with per-command calls that pass:
  • require_mission_binding=True for: discover, status, map add, map list, sync pull, sync push, sync run, sync publish, unbind
  • require_mission_binding=False for: providers, bind
  • probe_reachability=True for: sync pull, sync push, sync run, sync publish (commands that immediately need the network)
  • probe_reachability=False for everything else
  • On non-READY results, the command exits with code 1 and prints result.message followed by a blank line and result.next_action.
  • On ROLLOUT_DISABLED specifically, the command exits with code 1 — note that this state is normally unreachable from a tracker CLI command because the conditional registration in cli/commands/__init__.py hides the group entirely. The check remains as a defense-in-depth assertion in case the gate flips between import and invocation.

Programmatic callers (dashboard, daemon)

  • MAY call evaluate_readiness() to render status panels.
  • MUST NOT use the result to decide whether to start the daemon — that decision is owned by ensure_sync_daemon_running() and its DaemonIntent argument (see background_daemon_policy.md).

Test Requirements

tests/saas/test_readiness_unit.py (stubs):

  • Each ReadinessState has at least one positive test (the state is reached).
  • Wording is asserted byte-for-byte against the table above.
  • Order is asserted by combining failures and verifying the earlier check wins.
  • Reachability probe stub is called iff probe_reachability=True.
  • Binding probe stub is called iff require_mission_binding=True.
  • An exception inside any prerequisite probe yields HOST_UNREACHABLE (not a raised exception).

tests/saas/test_readiness_integration.py (real evaluator):

  • Uses tmp_path fixtures for auth/config/binding state.
  • Drives at least one happy path (READY) and three failure paths (MISSING_AUTH, MISSING_HOST_CONFIG, MISSING_MISSION_BINDING).
  • Reachability is exercised against a local stub server (no real network) — opt-in via probe_reachability=True.
  • Both rollout-on and rollout-off modes exercised via the shared fixtures.

tests/agent/cli/commands/test_tracker.py (parametrized):

  • Each tracker command has a row for rollout_disabled (asserts hidden) and rollout_enabled × prerequisite-state matrix (asserts the right per-prerequisite message reaches stdout).

saas_rollout.md

Contract: SaaS Rollout Gate

Module: src/specify_cli/saas/rollout.py Stability: Internal CLI surface, but public to all of src/specify_cli/. Treat as a stable internal contract.


Functions

is_saas_sync_enabled() -> bool

Inputs: None (reads process environment).

Returns: True iff the environment variable SPEC_KITTY_ENABLE_SAAS_SYNC is set to a truthy value:

  • "1"
  • "true" (case-insensitive)
  • "yes" (case-insensitive)
  • "on" (case-insensitive)

Returns False for:

  • Unset variable
  • Empty string
  • "0", "false", "no", "off"
  • Any other value

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

Performance: O(1).


saas_sync_disabled_message() -> str

Inputs: None.

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

> Hosted SaaS sync is not enabled on this machine. Set SPEC_KITTY_ENABLE_SAAS_SYNC=1 to opt in.


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
  • src/specify_cli/sync/feature_flags.py

Both shims re-export from specify_cli.saas.rollout and add no behavior. Any future change to the env-var contract is made once in saas/rollout.py.


Usage Contract for Callers

1. CLI Typer registration (src/specify_cli/cli/commands/__init__.py:37-40, 71-72):

  • MUST call is_saas_sync_enabled() at module import time.
  • MUST conditionally import the tracker module and conditionally app.add_typer(tracker_module.app, name="tracker").
  • MUST NOT register the tracker group when the gate is off, even with a runtime guard, so customers' --help output never lists it.

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

  • 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:

  • Use monkeypatch.setenv("SPEC_KITTY_ENABLE_SAAS_SYNC", "1") / monkeypatch.delenv(...).
  • The autouse fixture at tests/conftest.py:57-60 sets the gate ON by default; dual-mode tests opt out explicitly.

Test Requirements

  • tests/saas/test_rollout.py MUST cover at minimum:
  • Unset env var → False
  • Empty string → False
  • "1"True
  • "0"False
  • "true" / "TRUE" / "True"True
  • "yes" / "on"True
  • Garbage values ("banana") → False
  • The disabled message wording is byte-for-byte stable
  • mypy --strict clean