Contracts

history-store-query.md

Contract: UpgradeAttemptStore query interface

Module: src/specify_cli/compat/history.py FR: FR-013, FR-015, NFR-005, NFR-006, NFR-007 Consumers: readiness/upgrade_ux.py (_default_upgrade_runner)


Write contract: append()

def append(self, record: UpgradeAttemptRecord) -> None:

Guarantees: 1. Best-effort: any sqlite3.Error, OSError, or other exception is swallowed silently (fail-safe for appends). Callers MUST NOT depend on the append succeeding. 2. Idempotent write: a record with an attempt_id already present in the store is silently ignored (INSERT OR IGNORE). 3. Retention: after a successful insert, the store runs a trim to keep the last 200 records per install_method. The trim is part of the same transaction. 4. WAL mode: the store opens with PRAGMA journal_mode=WAL before any DML.

NFR-007 enforcement: implementors MUST NOT store sys.executable, user paths, project slugs, hostnames, or machine IDs in any column. Only install_method (a StrEnum value), intent, outcome, exit_code, target_version (a version string matching [A-Za-z0-9.\-+]{1,64} or None), attempt_id (ULID), and timestamp (UTC ISO datetime) are permitted.


Read contract: is_idempotent()

def is_idempotent(self, attempt: UpgradeAttemptRecord) -> bool:

Semantics: Returns True iff a record with outcome == 'success' AND the same install_method AND the same target_version already exists in the store.

Edge cases:

  • attempt.target_version is None → always returns False (cannot deduplicate unknown version).
  • Store unreachable → returns False (fail-open).

Acceptance scenario (FR-013, User Story 3, scenario 2): > Given two consecutive upgrade attempts with identical install_method and target_version, when the history store is queried, then is_idempotent(attempt) returns True for the second attempt if the first was a success.


Read contract: consecutive_failure_count()

def consecutive_failure_count(
    self,
    install_method: InstallMethod,
    *,
    window_seconds: int = 300,
) -> int:

Semantics: Returns the number of consecutive outcome == 'failure' records at the tail of the recent history for install_method, within window_seconds of now. Stops counting at the first non-failure record.

Scan bound: at most the last 100 records per install_method (SC-004). Records outside window_seconds are excluded before counting.

Edge cases:

  • No records → returns 0.
  • Store unreachable → returns 0 (fail-open).

Acceptance scenario (FR-015, User Story 3, scenario 3): > Given three consecutive failed attempts in a 5-minute window, when the history store is queried, then the store reports consecutive_failures=3 and the caller can apply a backoff policy without re-reading the NagCache.


Read contract: last_success_timestamp()

def last_success_timestamp(
    self, install_method: InstallMethod
) -> datetime | None:

Semantics: Returns the UTC datetime of the most recent record with outcome == 'success' for the given install_method, or None if no such record exists.

Edge cases:

  • No success records → returns None.
  • Store unreachable → returns None (fail-open).

Path resolution: default_history_db_path()

def default_history_db_path() -> Path:

Resolution order (same pattern as NagCache _resolve_cache_dir()): 1. SPEC_KITTY_HISTORY_DB_PATH env var, if set and non-empty. 2. platformdirs.user_cache_dir("spec-kitty") / "upgrade-history.db". 3. Manual XDG/OS fallback:

  • Linux: $XDG_CACHE_HOME/spec-kitty/upgrade-history.db or ~/.cache/spec-kitty/upgrade-history.db
  • macOS: ~/Library/Caches/spec-kitty/upgrade-history.db
  • Windows: %LOCALAPPDATA%\spec-kitty\Cache\upgrade-history.db

NFR-009: This path is SEPARATE from upgrade-nag.json (NagCache) and SEPARATE from ~/.spec-kitty/queue.db (OfflineQueue). The NagCache schema is NOT extended.


Security properties

CheckEnforcement
NFR-007 (no PII)attempt_id is a ULID (not derived from any path or user identity); target_version matches version regex or is None; no path columns
NFR-006 (concurrent-write safety)WAL journal mode; INSERT OR IGNORE for idempotent writes
NFR-005 (idempotency on read)UNIQUE index on attempt_id; INSERT OR IGNORE
Fail-safe appendsAll sqlite3.Error swallowed with # noqa: BLE001
Fail-open readsAll sqlite3.Error swallowed; return default (False / 0 / None)
File creationdb_path.parent.mkdir(parents=True, exist_ok=True) before first open

remediation-command-render.md

Contract: RemediationCommand.render(platform)

Module: src/specify_cli/compat/remediation.py FR: FR-005, NFR-002, C-005 Consumers: review/__init__.py, upgrade_ux.py, upgrade_hint.py, version_checker.py, schema_version.py


Signature

def render(self, platform: Literal["posix", "windows"]) -> str:
    """Return a CHK028-validated, env-prefixed, platform-quoted command string.

    Raises:
        ValueError: if `self.argv` is None (intent is MANUAL_GUIDANCE).
        ValueError: if the composed string does not match CHK028
                    (`^[A-Za-z0-9 .\\-+_/=:]{1,128}$`).

    The returned string is safe for copy-paste display. The same argv
    and env fields can be passed directly to subprocess.run() for
    programmatic execution.
    """

Composition rules

1. Env prefix

Build the env prefix from self.env (an ordered Mapping[str, str]):

PlatformFormat per entryJoin
"posix"KEY=shlex.quote(value)space-separated, trailing space
"windows"$env:KEY='<powershell-quoted-value>';space-separated, trailing space

PowerShell quoting: wrap value in single quotes; replace each ' in value with ''.

Example (posix, UV_TOOL_DIR=/opt/tools):

UV_TOOL_DIR=/opt/tools uv tool install --force spec-kitty-cli

Example (windows, UV_TOOL_DIR=C:\tools):

$env:UV_TOOL_DIR='C:\tools'; uv tool install --force spec-kitty-cli

2. Argv composition

Join self.argv elements with shlex.quote() for "posix". For "windows", join without additional quoting (Windows shell handles its own quoting; the PowerShell env prefix already uses safe quoting for the env values).

3. CHK028 validation

The final composed string (env_prefix + argv_string) MUST match:

_COMMAND_RE = re.compile(r"^[A-Za-z0-9 .\-+_/=:]{1,128}$")

If validation fails, raise ValueError with a CHK028 violation message. The caller MUST catch this and fall back to a MANUAL_GUIDANCE remediation with a safe note.


Acceptance scenarios

Install methodIntentPlatformExpected render() output
PIPXUPGRADEposixpipx upgrade spec-kitty-cli
PIPXUPGRADEwindowspipx upgrade spec-kitty-cli
UV_TOOL (default tool dir, no python)UPGRADEposixuv tool install --force spec-kitty-cli
UV_TOOL (custom UV_TOOL_DIR=/opt, python=3.11)UPGRADEposixUV_TOOL_DIR=/opt uv tool install --force --python 3.11 spec-kitty-cli
UV_TOOL (custom dirs)UPGRADEwindows$env:UV_TOOL_DIR='C:\tools'; uv tool install --force spec-kitty-cli
BREWUPGRADEposixbrew upgrade spec-kitty-cli
PIP_USERUPGRADEposixpip install --user --upgrade spec-kitty-cli
PIP_SYSTEMUPGRADEposixpip install --upgrade spec-kitty-cli
UNKNOWNUPGRADEposixraises or returns MANUAL_GUIDANCE note (no argv)

Backward compatibility with UpgradeHint.command

After WP03, build_upgrade_hint() reimplements by calling plan_remediation().render(current_platform). The returned UpgradeHint.command value must equal the pre-migration value for every install method in _HINT_TABLE. Snapshot tests committed in WP03 enforce this invariant (SC-003, SC-006).


Never-render contract for MANUAL_GUIDANCE

When intent == MANUAL_GUIDANCE, argv is None. Calling render() on a MANUAL_GUIDANCE command MUST raise ValueError("cannot render MANUAL_GUIDANCE RemediationCommand — check intent before calling render()"). The note field carries the human-readable message for these cases.