Contracts

error-envelope.md

Contract — CLI error-presentation hook & envelope

Home: global Typer error handler registered on the top-level spec-kitty app (src/specify_cli/cli/…). Single authority (C-002).

Behavior

Conditionstdoutstderrexit
GuardedReadError (or subclass), no --json—Error: <reason> (one line, names file/handle)1
GuardedReadError (or subclass), --jsonone JSON object (below)—1
Typer usage error (missing arg, bad option)—Typer's usage text2 (unchanged)
Any other exception—Python traceback(propagates)

JSON envelope (--json)

Exactly one object on stdout, nothing else on stdout:

{
  "error": "workflow file 'bad.yaml' is not valid: while parsing a block mapping ...",
  "kind": "WorkflowFileError",
  "path": "bad.yaml"
}

NonAsciiNameError carries the offending name as path or a dedicated field — finalized in WP06, but always a stable machine field).

  • error: the actionable human reason (same text as the non-JSON line).
  • kind: the concrete GuardedReadError subclass name.
  • path: the offending file/handle, or null when not path-scoped (e.g.

Invariants

(NFR-002).

converts a Typer usage error (exit 2) to exit 1 (D4).

tracebacks (D2).

gate); no command relies on its own bespoke emitter for the domain base.

  • INV-1: no in-scope error path emits non-JSON prose on stdout under --json
  • INV-2: the hook catches only the GuardedReadError base family; it never
  • INV-3: a non-domain exception is re-raised so genuine bugs remain visible as
  • INV-4: the hook is registered on the top-level app (asserted by the FR-011

guarded-read-primitive.md

Contract — kernel.read_guarded

Home: src/kernel/guarded_read.py · Layer: kernel (no schema/validation libs)

Signature (indicative)

T = TypeVar("T")

def read_guarded(
    path: Path,
    parse: Callable[[bytes | str], T],
    *,
    errors: tuple[type[BaseException], ...] = (),
    error_cls: type[GuardedReadError] = GuardedReadError,
    mode: Literal["bytes", "text"] = "text",
) -> T:
    ...

Guarantees

1. Reads path once (mode="text" → utf-8 decode; mode="bytes" → raw). No second read, no extra open beyond the single read (NFR-003). 2. Applies parse to the read content and returns its result unchanged on success. 3. On any of (OSError, UnicodeDecodeError, *errors) raised by the read or by parse, raises error_cls(path=str(path), reason=<actionable message>) — a GuardedReadError subclass. The original is chained (raise … from exc) for logs, but the message the hook prints carries no stack. 4. Any exception not in the declared set propagates unchanged (a real bug stays a traceback). 5. Never partially writes or mutates state (read-only).

Caller responsibility

WorkflowFile.model_validate, tomllib.load, str) and the format-specific exception tuple (json.JSONDecodeError, yaml.YAMLError, pydantic.ValidationError, tomllib.TOMLDecodeError, KeyError/TypeError for missing keys).

(WorkflowFileError, ReleasePyprojectError, MissionMetaReadError, …).

  • The caller supplies the format decoder (json.loads, yaml.safe_load +
  • The caller chooses the error_cls subclass appropriate to its domain

Non-goals

presentation-seam territory (a domain error raised directly), not a read.

  • No schema knowledge in the primitive (kernel imports no pydantic/tomllib/yaml).
  • Not a validator for operator input strings (e.g. name slugs) — that is