Contracts

audit-row-family.md

Contract: Audit row-family classifier (for #1122)

Module: src/specify_cli/audit/shape_registry.py, src/specify_cli/audit/detectors.py WP: WP01

API surface

A new public predicate in shape_registry.py:

def is_mission_lifecycle_row(row: Mapping[str, Any]) -> bool:
    """Return True iff `row` matches the mission-lifecycle row family.

    A row is a mission-lifecycle row iff it carries both:
      - aggregate_type == "Mission"
      - a non-empty `event_type` string
    """

The detector currently at src/specify_cli/audit/detectors.py:55 consults this predicate:

FORBIDDEN_KEYS: frozenset[str] = frozenset({"event_type", "event_name"})

def detect_forbidden_keys(row: Mapping[str, Any], ...) -> Iterator[Finding]:
    if is_mission_lifecycle_row(row):
        return  # lifecycle rows legitimately carry `event_type`
    for key in FORBIDDEN_KEYS:
        if key in row:
            yield Finding(code="FORBIDDEN_KEY", detail=f"forbidden key: {key!r}", ...)

Behavioral contract

Input rowExpected behavior
{"from_lane": "planned", "to_lane": "claimed", "wp_id": "WP01", ...}Status-transition row. FORBIDDEN_KEY check runs; passes (no event_type/event_name).
{"aggregate_type": "Mission", "event_type": "MissionCreated", ...}Lifecycle row. FORBIDDEN_KEY check is skipped. No finding emitted.
{"aggregate_type": "Mission", "event_type": "SpecifyStarted", ...}Same as above. No finding.
{"event_type": "Foo"} (no aggregate_type)Not a lifecycle row by classifier. FORBIDDEN_KEY check runs; flags event_type.
{"aggregate_type": "Mission"} (no event_type)Not a lifecycle row by classifier. FORBIDDEN_KEY check runs; passes (no event_type).
{"from_lane": "planned", "to_lane": "claimed", "event_type": "X"}Malformed (carries transition AND lifecycle discriminators). Not a lifecycle row (no aggregate_type=Mission). FORBIDDEN_KEY check runs; flags event_type.

TeamSpace gate downstream

src/specify_cli/audit/models.py:19-30 keeps FORBIDDEN_KEY in TEAMSPACE_BLOCKER_CODES. The behavioral result is:

  • A fresh mission's lifecycle rows do not generate FORBIDDEN_KEY findings → no TeamSpace blocker → spec-kitty sync now connects.
  • A genuinely malformed status-transition row carrying event_type still produces a FORBIDDEN_KEY finding → still blocks TeamSpace migration → the regression guard remains.

Test surface (WP01)

tests/specify_cli/audit/test_detectors_row_family.py:

  • One test per row shape in the table above.
  • One scenario test that runs spec-kitty agent mission create in a tmp project + spec-kitty doctor mission-state --audit --json and asserts zero FORBIDDEN_KEY findings.
  • One regression test that injects a synthetic status-transition row with event_type and asserts the audit still flags it.

doctor-restart-daemon.md

Contract: spec-kitty doctor restart-daemon + remediation-hint refresh (for #1124)

Modules: src/specify_cli/cli/commands/doctor.py, src/specify_cli/sync/preflight.py WP: WP03

CLI surface

$ spec-kitty doctor restart-daemon [--json]

Stop the registered sync daemon and respawn it at the foreground
executable / source recorded in the daemon owner record.

Exit codes:
  0  Daemon restarted successfully (or no daemon was running and one is now launched matching the foreground).
  1  No registered daemon and no foreground binding available to launch one. Operator must run `spec-kitty sync now`.
  2  Daemon stop succeeded but respawn failed. The system is left in a stopped state; the underlying error is reported.
  3  Daemon stop failed (process unresponsive). The owner record was not consumed. Operator can retry or use `pkill`.

--json emits a single object with status, previous_pid, new_pid, and error fields.

Behavioral contract

PreconditionActionOutcome
Registered daemon running, foreground matches ownerStop existing process via owner pid; relaunch via existing daemon launcher with owner's executable_path / source_pathExit 0; new pid recorded in owner record.
Registered daemon running, foreground does NOT match ownerStop existing process; relaunch using foreground executable/source (not the stale owner's)Exit 0; owner record now binds to foreground.
Owner record absentNo daemon to stopExit 1 with an actionable message: "no registered daemon — run spec-kitty sync now to launch one".
Owner record present, process already deadSkip stop; clean up stale lock; relaunchExit 0; surface a "stale owner record cleaned" notice.
Stop hangs (process unresponsive)Owner record left intactExit 3 with hint to investigate / pkill -f run_sync_daemon.

Composition contract

restart-daemon is implemented as a composition of existing primitives:

def restart_daemon(repo_root: Path, *, foreground: ForegroundIdentity) -> RestartResult:
    owner = read_owner_record(repo_root)
    if owner is None:
        return RestartResult(status="no_owner", exit_code=1, ...)
    stop_result = stop_registered_daemon(owner)  # existing primitive used by `sync stop`
    if not stop_result.ok and stop_result.kind != "already_dead":
        return RestartResult(status="stop_failed", exit_code=3, ...)
    launch_result = launch_daemon_for_foreground(foreground)  # existing primitive used by `sync now`
    if not launch_result.ok:
        return RestartResult(status="respawn_failed", exit_code=2, ...)
    return RestartResult(status="restarted", exit_code=0, previous_pid=..., new_pid=...)

The CLI command in doctor.py is a thin typer wrapper around restart_daemon.

Remediation hint refresh

src/specify_cli/sync/preflight.py currently mentions spec-kitty doctor restart-daemon in 4 hint strings + 1 comment:

  • line 99 — _REMEDIATION_HINTS["..."]
  • line 103 — _REMEDIATION_HINTS["..."]
  • line 107 — _REMEDIATION_HINTS["..."]
  • line 119 — _REMEDIATION_HINTS["..."]
  • line 218 — explanatory comment

After WP03 lands, each hint: 1. References spec-kitty doctor restart-daemon (which now exists). 2. Optionally appends a secondary remedy hint for the case where the operator wants to verify the restart (spec-kitty sync status --check to confirm). 3. Uses uniform wording across the 4 strings so a future grep stays consistent.

Test surface (WP03)

tests/specify_cli/cli/commands/test_doctor_restart_daemon.py:

  • Happy path: launch a fake daemon (process double), invoke doctor restart-daemon, assert exit 0 and new pid != old pid.
  • No owner: invoke with no owner record on disk, assert exit 1 and the actionable error mentions spec-kitty sync now.
  • Stop fails: simulate a hung daemon, assert exit 3 and owner record left intact.
  • Respawn fails: simulate launcher failure post-stop, assert exit 2 and no daemon left running.
  • Foreground binding: simulate mismatched owner; assert new owner record is bound to foreground after restart.

tests/specify_cli/sync/test_preflight_remediation_hints.py:

  • Hint coverage: every _REMEDIATION_HINTS entry that mentions a spec-kitty … command, parsed and shell-invoked under --help, must exit 0 (i.e., the command actually exists on the installed CLI).
  • Wording uniformity: assert all 4 hint strings reference doctor restart-daemon in the same canonical phrase.

sync-status-check-rendering.md

Contract: sync status --check path rendering (for #1123)

Module: src/specify_cli/cli/commands/sync.py (≈line 1856, boundary_table) WP: WP02

Rendering contract

The sync status --check text renderer composes two surfaces:

1. Identity Table — a Rich Table (unchanged: title="Identity Boundary", show_header=False, box=None, expand=False). Holds tabular identity scalars (version, server URL parity flag, foreground/daemon match indicator). 2. Path rows — rendered via Console.print(f"{label}: {path}") outside the Table. One line per path, label and value separated by ": ".

Rendering order: Path rows printed immediately above OR below the Identity Table; order is deterministic.

Behavioral guarantees

PropertyGuarantee
active_queue.path text == active_queue.path JSONbyte-identical for every path field.
Single-line patha path is never wrapped, never ellipsised (), never folded across lines.
Non-TTY captureidentical guarantees under subprocess.run([...sync status --check...], capture_output=True) and pipes.
Wide TTY operator UXpath rows render alongside the Table; no visual regression.

Renderer skeleton (illustrative, not normative)

def _render_boundary_check(...):
    console = Console()

    # Path rows first (outside the table)
    for label, path in [
        ("Active queue DB", state.active_queue_db_path),
        ("Foreground executable", state.foreground_executable_path),
        ("Foreground source", state.foreground_source_path),
        # ...etc — all canonical file path fields
    ]:
        if path is not None:
            console.print(f"{label}: {path}")

    # Identity scalars next (inside a table)
    boundary_table = Table(title="Identity Boundary", show_header=False, box=None, expand=False)
    boundary_table.add_column("Key", style="dim")
    boundary_table.add_column("Value")
    for key, value in identity_scalars(state):
        boundary_table.add_row(key, value)
    console.print(boundary_table)

JSON contract (unchanged, asserted)

sync status --check --json continues to expose active_queue.path and every other canonical path as a discrete string value. The text contract converges on the JSON contract for every path field.

Test surface (WP02)

tests/specify_cli/cli/commands/test_sync_status_check_paths.py:

  • Non-TTY capture test: spawn spec-kitty sync status --check under CliRunner (forces non-TTY), parse stdout, locate every label/path line, assert each path equals the corresponding --json value byte-for-byte.
  • Long-path test: seed a fixture where the queue DB path is > 80 chars; assert no appears in the rendered output.
  • Narrow-column TTY test: force Console(width=40) for the path renderer; confirm paths still render on one line (i.e., they bypass the Table width).
  • JSON parity test: compare every path field between text and JSON forms.