Skip to content
C.W.K.
Lesson 04 of 05 · published

A Closed Set of Failures

~14 min · error-handling, error-codes, observability, contracts

Level 0Loose Note
0 XP0/40 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

Twenty-Eight Names

Most code treats errors as an open set: any exception may come out of anything, and a caller handles the few it has heard of. Soul Vault treats failure as part of the contract. root.py defines ERROR_CODES, a frozen set of exactly twenty-eight names, and every failure any implementation raises is a VaultError carrying one of them, plus a cause that says what was actually observed.

Grouped by what they are about:

  • The pointer: pointer_unreadable, pointer_schema_error, pointer_regressed, backend_not_available, stale_pointer_generation.
  • The store: backend_unavailable, service_unavailable, stale_backend_data, integrity_error, unsupported_capability.
  • Identity: soul_not_found, identity_document_missing, unresolved_vault_reference, bootstrap_stale, invalid_soul_spec, lifecycle_pending.
  • Notes and writes: invalid_note_id, note_not_found, already_exists, read_only_note, revision_conflict, external_write.
  • Who may write, and when: policy_denied, rollout_hold, writer_sealed.
  • Across machines: canonical_unreachable, outcome_unknown.
  • The admission of a defect: internal_error.

Closed Means Enforced

The set is closed in code, not in a style guide. VaultError's constructor checks its own code: a name that is not in ERROR_CODES is rewritten to internal_error with the cause "unknown error code ...". An empty cause becomes "no cause was given (a defect in the raiser)". Any other exception that escapes a child, a raw OSError, a KeyError, is a defect by definition. The root does not catch it; the edges that face a caller do. The CLI and the identity refusal line both report it as internal_error with the exception's type, so a caller never sees an unnamed failure. And the root's own rule for children counts it as a test failure: it means a child met a situation and forgot to classify it.

That last rule changes how implementations get written. In ordinary code, letting an OSError bubble up is the lazy default. Here it is a test failure, so a child author has to decide, for every way storage can break, which named failure it is. That decision is exactly the thinking a caller needs done for it.

A Failure Is a Document

Every VaultError can turn itself into a plain dictionary with document(): the code, the cause, the operation, the backend, the pointer generation, the soul and note involved, a detail dictionary, and whether retrying could help. That is how a failure travels between Macs. When the canonical store refuses a write sent from another Mac, it answers with that document, and the calling Mac raises the same VaultError again under the same name. A revision_conflict on the canonical Mac is a revision_conflict on the laptop, with the current revision in detail so the caller can reread and rebase.

Names Are for People Too

The codes are not only for programs. When identity cannot load, the session's refusal line is built from the same two fields: identity not loaded: <code>: <cause>. Dad reads it in a chat. Watchfire reads it in a receipt. A future session reads it in a log. Because the code is from a closed set, each of them can look up exactly what it means; because the cause says what was seen, none of them has to guess why.

The architecture document goes one step further with a failure-mode table: one row per failure, how it is detected, and what the reader sees. The design rule "concrete-first" is written against that table: a new subsystem earns its place only by a row it is needed for.

Code

The constructor that keeps the set closed (from soulvault/root.py)·python
class VaultError(Exception):
    """A named failure. code is from ERROR_CODES; cause says what was observed."""

    def __init__(self, code, cause, *, operation=None, backend=None, generation=None,
                 soul=None, note=None, detail=None, retryable=False):
        if code not in ERROR_CODES:
            code, cause = "internal_error", f"unknown error code {code!r}: {cause}"
        if not str(cause or "").strip():
            cause = "no cause was given (a defect in the raiser)"
        super().__init__(f"{code}: {cause}")
        self.code, self.cause = code, str(cause)
        self.operation, self.backend, self.generation = operation, backend, generation
        self.soul, self.note = soul, note
        self.detail, self.retryable = dict(detail or {}), retryable

    def document(self) -> dict:
        return {"error": self.code, "cause": self.cause, "operation": self.operation,
                "backend": self.backend, "generation": self.generation, "soul": self.soul,
                "note": self.note, "detail": self.detail, "retryable": self.retryable}
Raising the same failure again on the other side (pattern)·python
def raise_remote(answer: dict) -> None:
    """A refusal that crossed the network is raised again under its own name."""
    if "error" in answer:
        raise VaultError(answer["error"], answer["cause"],
                         operation=answer.get("operation"), backend=answer.get("backend"),
                         generation=answer.get("generation"), soul=answer.get("soul"),
                         note=answer.get("note"), detail=answer.get("detail"),
                         retryable=bool(answer.get("retryable")))

try:
    vault.write_note(note, text, expected_revision=rev, actor=me, request_id=rid)
except VaultError as e:
    if e.code == "revision_conflict":
        current = e.detail["current_revision"]   # reread at this revision and rebase
    else:
        raise

External links

Exercise

List every distinct failure a component of yours can produce today, by reading its code rather than its docs. Group them, give each a stable name, and define an error class whose constructor rewrites any unknown name to an internal-error code and refuses an empty cause. Add a document() method and a function that raises the same error again from that document.
Hint
When you find yourself unable to name a failure, that is a design gap, not a naming problem: you do not yet know what the caller should do about it. Write that question down next to the code.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.