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 row | Expected 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_KEYfindings → no TeamSpace blocker →spec-kitty sync nowconnects. - A genuinely malformed status-transition row carrying
event_typestill produces aFORBIDDEN_KEYfinding → 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 createin a tmp project +spec-kitty doctor mission-state --audit --jsonand asserts zeroFORBIDDEN_KEYfindings. - One regression test that injects a synthetic status-transition row with
event_typeand 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
| Precondition | Action | Outcome |
|---|---|---|
| Registered daemon running, foreground matches owner | Stop existing process via owner pid; relaunch via existing daemon launcher with owner's executable_path / source_path | Exit 0; new pid recorded in owner record. |
| Registered daemon running, foreground does NOT match owner | Stop existing process; relaunch using foreground executable/source (not the stale owner's) | Exit 0; owner record now binds to foreground. |
| Owner record absent | No daemon to stop | Exit 1 with an actionable message: "no registered daemon — run spec-kitty sync now to launch one". |
| Owner record present, process already dead | Skip stop; clean up stale lock; relaunch | Exit 0; surface a "stale owner record cleaned" notice. |
| Stop hangs (process unresponsive) | Owner record left intact | Exit 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_HINTSentry that mentions aspec-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-daemonin 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
| Property | Guarantee |
|---|---|
active_queue.path text == active_queue.path JSON | byte-identical for every path field. |
| Single-line path | a path is never wrapped, never ellipsised (…), never folded across lines. |
| Non-TTY capture | identical guarantees under subprocess.run([...sync status --check...], capture_output=True) and pipes. |
| Wide TTY operator UX | path 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 --checkunderCliRunner(forces non-TTY), parse stdout, locate every label/path line, assert each path equals the corresponding--jsonvalue 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.