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.