Contracts
sync-boundary-preflight.md
Contract: SyncBoundaryPreflight
Module: src/specify_cli/sync/preflight.py (NEW) Mission: mvp-cli-sync-boundary-completion-01KRX11M
This contract specifies the public API and behavior of the reusable preflight helper that gates SaaS-producing CLI commands.
Public API
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
from rich.console import Console
# Canonical mismatch field names — must match Domain Language in spec.md.
MismatchField = Literal[
"daemon_package_version",
"daemon_executable_path",
"daemon_source_path",
"daemon_server_url",
"daemon_team_or_user",
"daemon_queue_db_path",
]
@dataclass(frozen=True)
class ForegroundIdentity:
package_version: str
executable_path: Path
source_path: Path
server_url: str | None
team_or_user: str | None
queue_db_path: Path
pid: int
@dataclass(frozen=True)
class OwnerMismatch:
field: MismatchField
foreground_value: str
daemon_value: str
remediation_hint: str
@dataclass(frozen=True)
class PreflightResult:
ok: bool
mismatches: tuple[OwnerMismatch, ...] = ()
orphan_records: tuple[Any, ...] = () # DaemonOwnerRecord; opaque to callers
legacy_event_rows: int = 0
legacy_body_upload_rows: int = 0
auth_present: bool = False
auth_required: bool = True
@property
def legacy_rows_for_scope(self) -> int:
return self.legacy_event_rows + self.legacy_body_upload_rows
def render(self, console: Console) -> None: ...
def to_dict(self) -> dict[str, Any]: ...
def collect_foreground_identity(repo_root: Path) -> ForegroundIdentity: ...
def run_preflight(
*,
repo_root: Path,
foreground: ForegroundIdentity | None = None,
require_auth: bool = True,
) -> PreflightResult: ...
Behavior
collect_foreground_identity(repo_root)
- Returns a
ForegroundIdentitypopulated from process state and hosted-auth config. server_urlandteam_or_userareNoneiff hosted auth is absent.- Pure / side-effect-free (reads files, no writes).
run_preflight(...)
Read-only check. Composes the following in order:
1. Resolve foreground if not supplied via collect_foreground_identity(repo_root). 2. Read DaemonOwnerRecord at owner_record_path() if present. 3. If the record exists and is not orphaned, build OwnerMismatch entries by comparing each canonical field. A field is considered mismatched when the foreground and daemon values differ. Missing values on either side are rendered as <unset> and counted as a mismatch only when one side has a concrete value and the other does not. 4. Collect orphan records via list_orphan_records() (existing). 5. Count legacy event-class rows and body-upload-class rows for the current scope via the extended detect_legacy_rows_for_scope(). The scope is the foreground's (server_url, team_or_user) tuple. 6. Determine auth_present from foreground.server_url is not None and foreground.team_or_user is not None. 7. Compute ok per the invariant in data-model.md.
The helper does not mutate state and does not call SaaS endpoints.
PreflightResult.render(console)
Default human-readable output:
Sync boundary refused: <N> mismatched field(s); <M> orphan daemon record(s); <K> legacy rows in scope.
Mismatches:
┌──────────────────────────────┬──────────────────────┬──────────────────────┐
│ Field │ Foreground │ Daemon │
├──────────────────────────────┼──────────────────────┼──────────────────────┤
│ daemon_package_version │ 3.2.0rc11 │ 3.2.0rc10 │
│ daemon_executable_path │ /usr/local/bin/uv │ /Users/.../bin/uv │
└──────────────────────────────┴──────────────────────┴──────────────────────┘
Remediation:
• Run `spec-kitty doctor restart-daemon` to restart the daemon at the foreground source.
• Run `spec-kitty doctor orphan-daemons` to clean up <M> orphan daemon record(s).
• Run `spec-kitty sync now` to flush <K> legacy rows for the current scope after the boundary is coherent.
When ok is True, render() is a no-op.
PreflightResult.to_dict()
Returns a JSON-serializable dictionary with all dataclass fields plus the computed legacy_rows_for_scope and ok keys. Used by --json flag paths in sync status --check and the preflight's optional debug surface.
Caller contract
Every SaaS-producing CLI entry point MUST:
1. Call run_preflight(repo_root=..., require_auth=True) after its own input validation and hosted-auth presence preflight, and before any code path that:
- writes a row to the scoped queue DB,
- writes a row to
body_upload_queue, - flushes rows to SaaS, or
- reads-then-acts on SaaS endpoints in a way that requires identity coherence.
2. If result.ok is False:
- call
result.render(console), - exit with code
2(matches existing_require_daemon_owner_coherenceexit code).
3. If result.ok is True:
- proceed with the original command logic.
Test surface
Tests SHALL cover:
run_preflightreturnsok=Truewhen no owner record exists and the foreground is authenticated and the scoped queue holds the legacy rows.run_preflightreturnsok=Falsewith adaemon_package_versionmismatch when the owner record's version differs from foreground.- Same for each of the other five canonical field names (
daemon_executable_path,daemon_source_path,daemon_server_url,daemon_team_or_user,daemon_queue_db_path). run_preflightreturnsok=Falsewithorphan_recordsnon-empty when an orphan owner record exists (using a written-on-disk fixture; not by invokingos.kill).run_preflightreturnsok=Falsewithlegacy_rows_for_scope > 0when the legacy queue contains rows for the current scope.legacy_body_upload_rows > 0triggers refusal independently oflegacy_event_rows.PreflightResult.renderproduces ≤ 25 visible lines for ≤ 6 mismatches and ≤ 3 orphan records (NFR-004).auth_required=Trueandauth_present=Falseproducesok=Falseeven when no daemon record exists.
Performance contract
run_preflight SHALL complete in ≤ 100 ms on a coherent host (NFR-003). The helper does not perform SaaS round-trips; it reads owner record, queries SQLite counts, and inspects process state.
Cross-platform contract (C-008)
The helper SHALL behave identically on Linux, macOS, and Windows 10+ per the project charter:
- All file-system paths use
pathlib.Path; no string-separator assumptions. - Home-directory lookups go through
pathlib.Path.home()(resolvesUSERPROFILEon Windows,HOMEon POSIX) rather than readingos.environ["HOME"]directly. - Tests isolate the operator's home directory by patching
pathlib.Path.home()so the same fixtures run on all three platforms.
Backwards-compatibility contract
- The existing
_require_daemon_owner_coherence()helper (src/specify_cli/cli/commands/sync.py:342) is rewritten to delegate torun_preflight(...). Its public signature is preserved. - The existing
_build_boundary_check_failures()helper (src/specify_cli/cli/commands/sync.py:1286) is rewritten to share its failure-detection logic withrun_preflight(single source of truth), but its return shape is preserved. - No on-disk format changes; no SQLite schema changes; no SaaS payload changes.
sync-status-output.md
Contract: sync status --check output and exit code
Module: src/specify_cli/cli/commands/sync.py (existing — extended) Mission: mvp-cli-sync-boundary-completion-01KRX11M
This contract specifies the printed fields, exit code, and --json shape of spec-kitty sync status --check after this mission lands.
Exit code
| Condition | Exit code |
|---|---|
Boundary coherent, auth present (when SPEC_KITTY_ENABLE_SAAS_SYNC=1), no orphans, no legacy rows for scope | 0 |
Foreground vs daemon mismatch on any of: daemon_package_version, daemon_executable_path, daemon_source_path, daemon_server_url, daemon_team_or_user, daemon_queue_db_path | 2 |
| Orphan daemon owner record present | 2 |
Legacy queue contains rows belonging to current scope (legacy_rows_for_scope > 0) | 2 |
SPEC_KITTY_ENABLE_SAAS_SYNC=1 set but no authenticated identity available | 2 |
Multiple conditions remain 2 (no mapping per condition); the body of the output names every failing field/category.
Default (human-readable) printed fields
Every invocation of sync status --check, regardless of exit code, MUST print exactly these fields, in this order, with these labels:
Identity boundary:
Foreground:
Package version : <foreground.package_version>
Executable path : <foreground.executable_path>
Source path : <foreground.source_path>
Server URL : <foreground.server_url or "<unset>">
Team/User : <foreground.team_or_user or "<unset>">
Queue DB path : <foreground.queue_db_path>
Daemon owner record:
Status : <"present" | "absent" | "orphan">
PID : <record.pid or "<absent>">
Port : <record.port or "<absent>">
Package version : <record.package_version or "<absent>">
Executable path : <record.executable_path or "<absent>">
Source path : <record.source_path or "<absent>">
Server URL : <record.server_url or "<absent>">
Team/User : <record.team_or_user or "<absent>">
Queue DB path : <record.queue_db_path or "<absent>">
Active queue:
Path : <foreground.queue_db_path>
Event count : <N>
Body upload cnt : <M>
Legacy queue:
Path : <legacy_queue_db_path>
Event count : <K>
Body upload cnt : <L>
Rows in scope : <legacy_rows_for_scope>
Mismatches : <0..6>
Orphan records : <0..N>
When exit code is 2, a "Mismatches" subsection lists each failing field with foreground and daemon values plus a one-line remediation hint per the preflight contract.
--json mode
When the command is invoked with --check --json, the human-readable block is suppressed and the output is a single JSON object on stdout:
{
"ok": false,
"exit_code": 2,
"foreground": {
"package_version": "3.2.0rc11",
"executable_path": "/usr/local/bin/uv",
"source_path": "/Users/.../site-packages/specify_cli",
"server_url": "https://spec-kitty-dev.fly.dev",
"team_or_user": "team:abc123",
"queue_db_path": "/Users/.../.spec-kitty/scopes/team-abc123/queue.db",
"pid": 12345
},
"daemon_owner_record": {
"status": "present",
"pid": 67890,
"port": 8765,
"package_version": "3.2.0rc10",
"executable_path": "/usr/local/bin/uv",
"source_path": "/Users/.../site-packages/specify_cli",
"server_url": "https://spec-kitty-dev.fly.dev",
"team_or_user": "team:abc123",
"queue_db_path": "/Users/.../.spec-kitty/scopes/team-abc123/queue.db"
},
"active_queue": {
"path": "/Users/.../.spec-kitty/scopes/team-abc123/queue.db",
"event_count": 0,
"body_upload_count": 0
},
"legacy_queue": {
"path": "/Users/.../.spec-kitty/queue.db",
"event_count": 3,
"body_upload_count": 1,
"rows_in_scope": 4
},
"mismatches": [
{
"field": "daemon_package_version",
"foreground_value": "3.2.0rc11",
"daemon_value": "3.2.0rc10",
"remediation_hint": "Run `spec-kitty doctor restart-daemon` ..."
}
],
"orphan_records": []
}
Field names match the dataclass field names in data-model.md and contracts/sync-boundary-preflight.md.
Test surface
Tests SHALL cover:
- Coherent host → exit 0 and prints all required fields (none absent).
- Daemon version drift → exit 2 and prints the
daemon_package_versionmismatch with foreground+daemon values. - Each remaining canonical mismatch field independently → exit 2 with that field named.
- Orphan owner record → exit 2 with orphan count ≥ 1 and
status="orphan"(or"present"plus orphan list per implementation). - Legacy queue rows in scope → exit 2 with
legacy_queue.rows_in_scope > 0. --check --jsonproduces a single JSON object on stdout with the documented shape.
Test files: tests/sync/test_sync_status_boundary_check.py (existing — extended).
Backwards compatibility
- The current
sync status(without--check) output is unchanged outside the identity-boundary subsection. - Previously detected non-zero conditions remain non-zero; this contract only adds fields and refusal conditions.