The one error struct this package returns, and the closed vocabulary of reasons it carries.
Every non-bang entry point returns {:ok, value} or
{:error, %Encryptor.Error{}}, and every bang variant raises this same
struct. There is no second error shape, no bare atom, and no error tuple
with a different arity depending on which layer failed.
The four fields
:reason- this package's own stable, matchable term. The complete set isreason/0, and it is closed: it is extended by an ADR, never by a call site inventing a near-miss.:vault- the vault module the operation ran against.:operation-:encrypt,:decrypt,:rekey, or:start.:engine- the underlying error term, unchanged, ornil.
The split between :reason and :engine is the point. Consumers match on
:reason, which this package owns and versions. Operators read :engine in
a log line when they need to know which keyring rejected what. Terms from
the engine - or from a provider's own stack, an Ecto changeset or an
ExAws tuple - are carried in :engine, never translated away and never
promoted into :reason. That is what keeps reason/0 an enumeration a
case can be written against.
The oracle rule
Every decrypt-side failure that depends on the message collapses to
:decrypt_failed. A wrong key, a failed authentication tag, an encryption
context mismatch, a required key absent from the message, and a commitment
policy rejection are indistinguishable in :reason. The detail lives in
:engine, for logs only.
Distinguishable decrypt failures are a decryption oracle, and the caller
cannot act differently on the distinctions anyway. decrypt_failed/3 is the
collapse point: the decrypt path builds its message-dependent failures
through it rather than by assembling a struct by hand.
Failures that depend only on caller-supplied arguments stay distinct, because they are not an oracle and the caller needs them: an unresolvable selector, a reserved context key, a required context key the caller omitted, a provider that could not answer at all. Provider resolution is carved out of the collapse in both directions - it happens before any ciphertext is examined, and reporting an unreachable key store as data corruption sends an operator looking for the wrong thing at three in the morning.
Messages never carry key-shaped values
Exception.message/1 renders :reason only, and only the parts of it that
are safe to print. It never renders :engine, and it never renders the
detail of {:invalid_key_descriptor, detail} or {:invalid_config, key, detail}, either of which can hold key material. Plaintext, data keys, and
wrapping key material never reach a message, a log line, or a failure
report.
Records: ADR-0001 decision 10, ADR-0002 decision 6, ADR-0004 decision 8.
Summary
Types
The vault operation an error arose from.
The complete reason vocabulary.
A key selector, as ADR-0004 decision 3 fixes it: a non-empty String.t() in
a :tenant vault, and the atom :default in a :single one.
Functions
Builds the collapsed failure for a decrypt-side condition that depends on the message.
Renders an error for a human.
Types
@type operation() :: :encrypt | :decrypt | :rekey | :start | :derive
The vault operation an error arose from.
:derive is ADR-0003 amendment A's addition. A derivation is none of the
other four, and reporting it as one of them would make an operator's error
line name a call the caller never made.
@type reason() :: :decrypt_failed | {:vault_not_started, module()} | {:missing_config, [atom()]} | {:invalid_config, atom(), term()} | {:unknown_key, selector()} | {:encryption_context_conflict, String.t()} | {:reserved_context_key, String.t()} | {:key_unavailable, selector()} | {:invalid_key_descriptor, term()} | {:provider_not_started, module()} | {:missing_optional_dependency, atom()} | {:missing_required_context_keys, [String.t()]} | {:invalid_context_value, String.t() | :count | :too_large} | {:invalid_selector, term()}
The complete reason vocabulary.
Assembled from the accepted records: ADR-0001 decision 10 fixes the first
seven, ADR-0002 decision 6 adds four for key resolution, and ADR-0004
decision 8 adds three for the encryption context. ADR-0005 adds none - a
rotation misconfiguration is an {:invalid_config, key, detail}.
@type selector() :: String.t() | :default
A key selector, as ADR-0004 decision 3 fixes it: a non-empty String.t() in
a :tenant vault, and the atom :default in a :single one.
Functions
Builds the collapsed failure for a decrypt-side condition that depends on the message.
The engine's term - or the vault's own term, when a check ran above the
engine - is carried in :engine unchanged. It is never inspected to pick a
reason, and it never reaches the rendered message.
iex> error = Encryptor.Error.decrypt_failed(MyApp.Vault, :decrypt, {:encryption_context_mismatch, "column"})
iex> error.reason
:decrypt_failed
iex> error.engine
{:encryption_context_mismatch, "column"}A rekey/2 failure on its decrypt half carries :rekey, because the
operation is what the caller asked for:
iex> Encryptor.Error.decrypt_failed(MyApp.Vault, :rekey, :key_mismatch).operation
:rekey
Renders an error for a human.
The rendered string carries the reason and where it happened, never the
:engine term and never a value that could be key-shaped.
iex> Encryptor.Error.message(%Encryptor.Error{reason: :decrypt_failed, vault: MyApp.Vault, operation: :decrypt})
"decryption failed (MyApp.Vault, decrypt)"
iex> Encryptor.Error.message(%Encryptor.Error{reason: {:missing_optional_dependency, :ecto}})
"missing optional dependency :ecto"