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 ForegroundIdentity populated from process state and hosted-auth config.
  • server_url and team_or_user are None iff 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_coherence exit code).

3. If result.ok is True:

  • proceed with the original command logic.

Test surface

Tests SHALL cover:

  • run_preflight returns ok=True when no owner record exists and the foreground is authenticated and the scoped queue holds the legacy rows.
  • run_preflight returns ok=False with a daemon_package_version mismatch 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_preflight returns ok=False with orphan_records non-empty when an orphan owner record exists (using a written-on-disk fixture; not by invoking os.kill).
  • run_preflight returns ok=False with legacy_rows_for_scope > 0 when the legacy queue contains rows for the current scope.
  • legacy_body_upload_rows > 0 triggers refusal independently of legacy_event_rows.
  • PreflightResult.render produces ≤ 25 visible lines for ≤ 6 mismatches and ≤ 3 orphan records (NFR-004).
  • auth_required=True and auth_present=False produces ok=False even 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() (resolves USERPROFILE on Windows, HOME on POSIX) rather than reading os.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 to run_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 with run_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

ConditionExit code
Boundary coherent, auth present (when SPEC_KITTY_ENABLE_SAAS_SYNC=1), no orphans, no legacy rows for scope0
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_path2
Orphan daemon owner record present2
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 available2

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_version mismatch 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 --json produces 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.

Event Journal Extension (#2124/#2131)

The event-sync-retention-delivery-01KVYWRG mission extends this contract purely additively (WP11, plan concern IC-08). Every existing top-level field documented above — ok, exit_code, foreground, daemon_owner_record, active_queue, legacy_queue, mismatches, orphan_records — remains available and unrenamed for old consumers. The extension only adds the seven nested sections below; it removes and renames nothing (SC-010).

Assembly lives in src/specify_cli/delivery/status_report.py (build_status_report(...)); the sync status CLI wiring stays thin and merges these sections onto the existing payload (WP12).

Required new JSON sections (exact keys):

resolved_server_url, user_id, team_slug, derived_queue_scope, queue_db_path, active_queue_scope_status. Makes env/config target disagreement observable and non-silent before any hosted network call.

oldest_retained_event_at (null when empty), journal_size_bytes, gc_suggested (bool), gc_suggestion (object with reason / retained_event_count / journal_size_bytes, or null).

scope; target_id is null when that target is not yet registered) and previous (a list of every other known target that has received deliveries, each with its identity and a delivered_count).

pending, rejected, transient — per-status counts.

last_error) of selector-excluded permanent failures (FR-015) that remain inspectable and are never deleted.

list of unresolved divergent-duplicate event_ids; while present, source-DB cleanup is blocked (FR-018).

body_upload_failure_log_count, owned by sync/queue.py and labeled to keep them explicitly separate from event-journal/ledger counts (C-006/NFR-006). No event_journal or delivery_ledger field is ever sourced from the body-upload tables.

  • target_authority: configured_server_url, env_server_url, override_mode,
  • event_journal: retained_event_count, archived_event_count,
  • delivery_targets: current (the resolved active target's identity — URL +
  • delivery_ledger: delivered_current_target, delivered_previous_target,
  • terminal_failures: count plus an events list (event_id, target_id,
  • migration_conflicts: count, cleanup_blocked (bool), and a conflicts
  • body_upload_compatibility: body_upload_queue_count and

Distinct counts (SC-003, US4): the retained event count, current-target delivered count, previous-target delivered count, terminal-failed count, body-upload count, and the oldest retained timestamp are each a separate value in its semantically-correct section above — none is a derived alias of another. For example, 124 events retained and delivered only to a previous target read as event_journal.retained_event_count = 124, delivery_ledger.delivered_previous_target = 124, delivery_ledger.delivered_current_target = 0, with a populated event_journal.oldest_retained_event_at.

Bounded-growth visibility (NFR-004): event_journal.journal_size_bytes is surfaced on every invocation so "explicit-only retention" never degrades into silent unbounded growth. The gc_suggested flag is gated: it is true only when the retained payloads are large (at or above a defined threshold, default 50 MiB) and every retained event is fully delivered to all known targets. With zero known targets the suggestion never fires.

Retention (FR-010, contract §3): sync gc / sync archive (logic in src/specify_cli/delivery/retention.py) are the only destructive payload operations and run only under explicit operator action — never as a side effect of sync now. Archive stamps the journal's archived marker (non-destructive); GC purges payload bytes only for events already delivered somewhere, preserving undelivered durability. Both preserve the delivery_ledger history and provenance.