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"
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
[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 toAUTO. - 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: ...
intentis mandatory and keyword-only. There is no default.configdefaults to a freshly loadedSyncConfigif not supplied (matches existing pattern).
Decision Matrix
is_saas_sync_enabled() | intent | policy | started | skipped_reason |
|---|---|---|---|---|
False | any | any | False | "rollout_disabled" |
True | LOCAL_ONLY | any | False | "intent_local_only" |
True | REMOTE_REQUIRED | MANUAL | False | "policy_manual" |
True | REMOTE_REQUIRED | AUTO | True (on success) | None |
True | REMOTE_REQUIRED | AUTO | False | "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:
| File | Call site purpose | New intent |
|---|---|---|
src/specify_cli/dashboard/server.py | Dashboard process startup | LOCAL_ONLY |
src/specify_cli/dashboard/handlers/api.py (read endpoints) | Returning local snapshot data | LOCAL_ONLY |
src/specify_cli/dashboard/handlers/api.py (explicit "sync now" endpoint) | User-initiated remote sync from dashboard | REMOTE_REQUIRED |
src/specify_cli/sync/events.py (event upload path) | Upload pending events to SaaS | REMOTE_REQUIRED |
src/specify_cli/cli/commands/tracker.py (sync pull, sync push, sync run, sync publish) | Direct user request | REMOTE_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 →
AUTOdefault. "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 runwithpolicy=MANUALexits 0 and prints the manual-mode message.sync runwithpolicy=AUTOreaches 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 §2ReadinessResult(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
| Parameter | Required | Default | Description |
|---|---|---|---|
repo_root | yes | — | Absolute path to the repo root; used to locate auth, config, and bindings. |
feature_slug | no | None | Active feature slug. Required when require_mission_binding=True. |
require_mission_binding | no | False | When True, an absent mission binding causes MISSING_MISSION_BINDING; otherwise binding is not checked. |
probe_reachability | no | False | When 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
| Step | Budget |
|---|---|
| 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:
messagenames the prerequisite explicitly (e.g., "No SaaS authentication token is present", not "not ready").next_actionprovides one concrete actionable step (e.g., "Runspec-kitty auth login").detailsMAY contain structured context but MUST NOT be the only source of human-readable information.
Stable wording (asserted by tests)
| State | message template | next_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=Truefor:discover,status,map add,map list,sync pull,sync push,sync run,sync publish,unbindrequire_mission_binding=Falsefor:providers,bindprobe_reachability=Truefor:sync pull,sync push,sync run,sync publish(commands that immediately need the network)probe_reachability=Falsefor everything else- On non-
READYresults, the command exits with code1and printsresult.messagefollowed by a blank line andresult.next_action. - On
ROLLOUT_DISABLEDspecifically, the command exits with code1— note that this state is normally unreachable from a tracker CLI command because the conditional registration incli/commands/__init__.pyhides 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 itsDaemonIntentargument (see background_daemon_policy.md).
Test Requirements
tests/saas/test_readiness_unit.py (stubs):
- Each
ReadinessStatehas 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_pathfixtures 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) androllout_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.pysrc/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'
--helpoutput never lists it.
2. Programmatic callers (daemon, dashboard, sync events):
- MUST call
is_saas_sync_enabled()directly OR rely onevaluate_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-60sets the gate ON by default; dual-mode tests opt out explicitly.
Test Requirements
tests/saas/test_rollout.pyMUST 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