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
| Condition | stdout | stderr | exit |
|---|---|---|---|
GuardedReadError (or subclass), no --json | — | Error: <reason> (one line, names file/handle) | 1 |
GuardedReadError (or subclass), --json | one JSON object (below) | — | 1 |
| Typer usage error (missing arg, bad option) | — | Typer's usage text | 2 (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 concreteGuardedReadErrorsubclass name.path: the offending file/handle, ornullwhen 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
GuardedReadErrorbase 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_clssubclass 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