View Source Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog and this project adheres to Semantic Versioning.
[1.9.0] - 2026-08-25
Added
usage-rules.md, shipped in the package — Errata's guidance condensed for AI coding agents, in the layoutusage_rulessyncs from. A consumer can pull it into theirAGENTS.mdwithmix usage_rules.sync, or read it atdeps/errata/usage-rules.md.It leads with the trap that costs the most time because it fails furthest from its cause — define error types in compiled code, since the generated
String.Charsand JSON protocol implementations are consolidated when your project compiles — thencreate/2vsnew/1, the cause chain, and the boundary rules. Two things it states explicitly that the guides leave implicit: every accessor raisesArgumentErroron a non-Errata value, which matters because the boundary where you ask is the boundary where foreign error shapes arrive; andto_error({:error, :timeout})does not unwrap the tuple.
Fixed
guidesis now included in the package. Thefiles:list was["lib", "mix.exs", "README.md", "LICENSE", "CHANGELOG.md"]while the README links intoguides/twelve times, so every one of those links was dead for anyone reading the package from Hex rather than from GitHub. Present since the docs were tiered in 1.5.0. HexDocs was never affected, sincemix hex.publishbuilds docs from the working tree.
Deprecated
Errata.root_cause/1. Useroot_error/1, orcause/1on it to reach the foreign original. The function still works and will until 2.0; calling it now produces a compiler warning naming the replacement.A cause chain is a chain of Errata errors, the deepest of which may carry a foreign original — the exception or value your code actually caught.
root_cause/1is the one accessor that does not fit that model: it returns an Errata error or a foreign value depending on how the chain ends, so a caller who wants to act on the result has to work out which it got. That is the same ergonomic problemroot_error/1was added to remove.# instead of Errata.root_cause(error) # to render, report or classify — always an Errata error: Errata.root_error(error) # to reach the original your code caught — or nil if there wasn't one: Errata.root_error(error) |> Errata.cause() # for a log, better than either — every level, with stacktraces: Errata.format_chain(error)root_cause(error)is exactlycause(root_error(error)) || root_error(error). In practice a call site wants one of the two halves rather than the union.This reverses a judgement made during 1.8.0, where
root_cause/1was kept on the grounds thatcause(root_error(e))returnsnilfor a chain ending in an Errata error and could be misread as "no cause". That argument only holds under the model this deprecation drops: once the chain is described as errors with an optional foreign original,nilthere means "no foreign original", which is unsurprising.The guides now lead with
root_error/1and state the chain model in one sentence.
[1.8.0] - 2026-08-24
Added
Errata.to_map/2takes an:onlyor:exceptprojection, so one error can be serialized two ways for its two audiences (#63).Errata.to_map(error) # the full record, for a reporter Errata.to_map(error, except: [:env]) # for a response body Errata.to_map(error, only: [:code, :message, :retryable]) # narrower stillto_map/1has always been a single serialization serving two consumers with opposite requirements: an error reporter, which wants everything, and an HTTP response body, which should carry as little as possible. It always included:env— which names the module, function, line, and source file where the error was created — and the boundaries guide's fallback controller led a reader straight into handing that to a client.The projection reaches aggregate members and a wrapped Errata cause, so
except: [:env]removes every:envin the structure rather than only the outermost one; leaving members':envin place would be a trap of exactly the kind the option exists to remove. A cause that is a plain exception rather than an Errata error is left alone, since it has no:envto drop and an:onlyprojection would mangle it. Keys are validated against the setto_map/1can produce, soexcept: [:envv]raises rather than quietly selecting nothing.The encoder protocols are unchanged and still emit the full map.
Jason.Encodertakes no options, and the reporting projection is the right default for what the protocols exist for — but that means encoding an error struct directly at a client-facing boundary still emits:env.guides/boundaries.mdnow says so under a warning admonition, and shows selecting fields explicitly instead.The cause chain now reaches
Errata.log/2andErrata.report/2metadata, under two new keys.Before this, a Logger backend could not recover the cause at all.
log/2logsException.message/1, which for a wrapped error is the outer message only — "the request could not be completed after 3 attempts", with "connection refused" nowhere — andlog_metadata/1carried no cause key and no error struct. The single most useful operator fact was absent from the library's own logging path, andguides/observability.mdnever mentioned the cause chain at all. Telemetry fared better only by accident: its metadata includes the:errorstruct, so a handler could dig the chain out itself.Two keys, for two kinds of consumer:
cause— the same nested mapto_map/1emits, so a structured log formatter or a telemetry handler gets every level of the chain with its owncode,contextand classification. Redaction applies at each level, and a foreign exception, an{:error, reason}tuple and a nested Errata error are all rendered for the consumer.caused_by— one greppable line naming the deepest failure, for a console reader or a single log field:"** (RuntimeError) connection refused".
Both are
nilfor an error with no cause.caused_byis deliberately not namedroot_cause:Errata.root_cause/1returns the error itself when there is no cause, and a metadata key that contradicted the function of the same name would be worse than a slightly different word.Exception.format_banner/2is used for exceptions andinspect/1for plain terms, since the former renders a bare atom as"** (ErlangError) Erlang error: :econnrefused", which buries the useful part.Both keys are additive, so existing consumers of the
[:errata, :error]metadata are unaffected.Errata.root_error/1, the deepest Errata error in a cause chain.A cause chain is Errata errors all the way down, ending in at most one foreign value — a bare atom, an
{:error, reason}tuple, a standard exception.cause/1only accepts Errata errors, so a foreign value can only ever be at the bottom. And that bottom value is frequently the least useful thing in the chain::econnrefusedhas no message, no context, no code and no classification, while the error wrapping it has all four.error = Errata.wrap(RetriesExhausted, :econnrefused, reason: :timeout) Errata.root_cause(error) #=> :econnrefused Errata.root_error(error) |> Errata.code() #=> "RETRIES_EXHAUSTED"The two differ exactly when the chain bottoms out in a foreign value, and are the same value otherwise. Which to reach for follows from that:
root_cause/1diagnoses what failed, and is what a developer wants in a log;root_error/1is the deepest thing that still carries Errata's structure, and is what you hand to a view, a reporter, or a retry decision.root_error/1always returns an Errata error, so a caller never has to check what it got, whereroot_cause/1may return either and leaves that to the caller.They are not two views of the same fact.
wrap/2keeps the cause in:causeand copies nothing out of it — a wrapped error's:reasonand:contextare untouched by what it wraps — so the two answers can be entirely different sentences, and neither is recoverable from the other's fields.This came out of the boundary recipe added below. Written against
root_cause/1, the recipe's consumer module needed a three-clausecase— with a clause-order trap, since an Errata error is also an exception — and aninspect/1fallback that would have shown a person the string":timeout". Written againstroot_error/1it is a pipeline with no branching:def user_message(value) do value |> Errata.to_error() |> Errata.root_error() |> Errata.display_message() endIt is also more correct. The old version surfaced
Exception.message/1from a foreign root cause:"connection refused"reads fine in a log, but on a screen it is text nobody wrote for a user, where the wrapping error'sdisplay_message/1is text somebody did.An application-wide fallback for types that declare no
:default_message(#64):config :errata, default_display_message: "an unexpected error occurred":default_messageis optional and the README presents the bare one-line definition as the normal starting point, so a type rendering asnilthroughdisplay_message/1,to_map/1, and the JSON encoding is easy to reach — and"error": nullin a response body is worse than a generic string. The library's own design guide worked around it with a|| "invalid request"at the point of use, which is the right advice but is one line every application repeats at every boundary.Defaults to
nil, so nothing changes until an application opts in. It applies only where the type declares nothing and the caller passed no:message— a declared:default_messageand a per-error:messageboth win — and it is read at runtime, mirroringconfig :errata, redact:, which is the closest existing analogue: a global floor that individual types refine. Becauseto_map/1dispatches through the generateddisplay_message/1, the fallback reaches the encoded map too, which is precisely the consumer with no other source for a message. The developer message (Exception.message/1) is unaffected.
Changed
Errata.root_cause/1now returns the error itself when the error has no cause, instead ofnil(#72). An error's chain includes the error, so the function is total: there is always a deepest thing in the chain, and it is the error when nothing is underneath it.error = OrderNotFound.new(reason: :not_found) Errata.root_cause(error) == error # was: nil Errata.cause(error) #=> nil, unchangedThe
nilwas doing a jobcause/1already does, and doing it in place of its own. Three things inside the library pointed the same way:format_chain/1already includes the error itself — it renders an uncaused error as a one-element chain.root_cause/1was the one function that modelled the chain as causes-only.errors/1already returns a total answer ([]for a non-aggregate) precisely so that "calling code never has to branch on whether it has an aggregate", which is how the guide describes it.root_cause/1returningnilwas the same decision made the other way.root_cause/1could not be implemented without the workaround it asked users to write. Its recursive clause readroot_cause(value) || value, because the recursive call returnednilfor an uncaused inner error. That||is now gone; the recursion is total.
A dogfooding application hand-wrote its own
root_cause/1rather than using this one, and the version it wrote returns the error itself — which is the behaviour the name leads people to expect.Upgrading. Code written the way the docs describe it —
Errata.root_cause(error) || error— keeps working unchanged, sinceerror || erroriserror; the|| errorcan now be deleted. What breaks, and breaks quietly, is code using thenilto detect the absence of a cause:# Now always truthy — this branch is dead. if Errata.root_cause(error), do: ..., else: ... # The nil clause is now unreachable. case Errata.root_cause(error) do nil -> ... cause -> ... endGrep for
root_causenearif,caseornil. In every such place the replacement isErrata.cause/1, which answers that question directly and is unchanged.The source path in a serialized error is now relative to the project root rather than absolute (#63).
to_map(error).env.filereadslib/my_app/orders.exinstead of naming the directory layout of the machine that compiled the code — which in a release built on a developer machine can include a username. TheErrata.Envstruct still holds the absolute path it was compiled with; only what crosses the wire changes.The root is captured when the error type is defined, which is during the using application's compile, because it cannot be recovered at runtime — a release's working directory is the release root, not the build tree. One consequence: if an application constructs an error type belonging to a library directly, the roots differ and the path is emitted unchanged, as before. Errors a library creates for itself, which is the ordinary case, are relative.
Fixed
- Unknown or misspelled
useoptions are now a compile-timeArgumentErrorinstead of being silently ignored (#62).
Previouslydefmodule Typo do use Errata.DomainError, htp_status: 404 end #=> ** (ArgumentError) invalid option(s) for Typo: [:htp_status]. #=> Valid options are [:default_reason, :default_message, :reasons, :http_status, :code, #=> :severity, :retryable, :redact, :aggregate].define/3validated the values of:reasonsand:aggregateand never looked at the key set, sohtp_status: 404produced a type with the default422anddefualt_message:producedmessage: nil— both plausible enough that nothing downstream looks broken. This is the same defect class as #3, which was fixed fornew/1andcreate/1params but not at theusesite, which is the worse of the two: a param typo is caught the first time that line runs, while auseoption is written once and the misconfiguration is permanent.It raises rather than warns, for consistency with thenew/1param check and with:reasons/:aggregatevalidation, and because the entire value of the check is that it cannot be scrolled past. Strictly this is a breaking change for code passing a stray key, but such code is by definition already not doing what its author intended.Two related tightenings fall out of the same allowlist::kindis now rejected byuse Errata.DomainErroranduse Errata.InfrastructureError, which set the kind themselves and previously ignored it. The message points atuse Errata.Error.- An invalid
:kindvalue (use Errata.Error, kind: :bogus) raisesArgumentErrornaming the three valid kinds, rather than a bare FunctionClauseError from the code generator.
Documentation
A new guide,
guides/testing.md(#69). There was no guidance on testing an application that uses Errata, and what existed was scattered — one line in a README tip box, one convention visible only by reading this repository's own test files. Three of the five things it covers produce failures that read as library bugs at first glance.Its core advice is to assert through the accessors. That was measured rather than assumed: on an identical wrong-reason assertion,
assert Errata.reason(error) == :xproduces a two-line diff naming the field, where nulling:envproduces ~16 lines, a whole-struct pattern match ~30, and plain==~40. Pattern matching is explicitly not recommended despite asserting correctly, because ExUnit expands the entire right-hand term on a failed match and the:envstacktrace swamps the output.Also covers where fixture types must be defined and why; the seam at which redaction has to be asserted (
error.contextstill holds the plaintext — checking it first is how redaction looks broken); the two things:redactstructurally cannot protect, with arefute_leaks/2recipe; the:telemetry_testidiom, which needs no new dependency; thatcapture_log/1needsmetadata: :allbefore the metadatalog/2exists for appears; and thatassert_raisematches the developer message rather than the display message.Every example is pinned by
test/errata/testing_guide_test.exs, followingdesign_guide_test.exs— the guide's examples are ExUnit assertions rather thaniex>sessions, so they cannot be doctests.An "unwrapping a wrapped error" recipe in
guides/wrapping-errors.md(#72). A dogfooding application hand-wrote a six-lineroot_cause/1loop and shipped it without noticingErrata.root_cause/1exists — and what it wrote differs from the built-in in the two ways that would have stopped it dropping into the call site anyway.root_cause/1returnsnilwhen there is no cause, which is the truthful answer to the question it asks but rarely the one a call site wants, soErrata.root_cause(error) || erroris the form to reach for — an idiom that appeared nowhere in the docs. And it raises on a non-Errata value, like every accessor, so a consumer holding whatever awithchain returned has to normalize first;to_error/2androot_cause/1compose, and the guides never paired them.root_cause/1was mentioned twice before, both times as the tail of a sentence about something else, and never under a heading a reader would scan for when the question is "this error's message is useless, how do I get to the real one".The recipe ends with a consumer-side module that only reads errors, which is also the natural place to show that the guards are
defguards: a module needsrequire Errataeven to call them fully qualified, and every other guide example picks that up invisibly viause Errata.A worked end-to-end boundary example in
guides/boundaries.md(#68). The guides showed the Phoenix fallback controller three times and never the view it renders through, so every adopter independently decided what an error looks like on the wire — the decision the:envexposure above makes easy to get wrong.The
to_map/2projection turns the view into a single call, and two properties of it do the work: the projection recurses into aggregate members, so each renders by the same rule including its owncode; and:errorsis absent for a non-aggregate, so one clause covers both shapes. That gives the validation-response case a worked example for the first time — it is what aggregates were built for, and nothing showed one crossing an HTTP boundary.
[1.7.0] - 2026-08-19
Added
Errata.from_map/3andErrata.from_map!/3(#48), the counterpart toto_map/1: an Elixir application that has an error type compiled can rebuild an error of that type from its encoded form, and get concrete-struct pattern matching and the guards back with it.{:ok, error} = Errata.from_map(MyApp.Orders.OrderNotFound, decoded_json) match?(%MyApp.Orders.OrderNotFound{}, error) #=> trueAccepts the map from
to_map/1directly or the result of decoding its JSON — string and atom keys both work.from_map/3returns{:ok, error} | {:error, reason}, since malformed input is an expected condition where this gets called;from_map!/3returns the error and raises, for payloads from somewhere you control. Passing a module that is not an Errata error type is a programming error and raises in both.The type is an argument, not read from the encoded
error_type. Resolving a module from a name off the wire would mean trusting that name and keeping a registry of every error type — the central registry that Errata's structuralis_error/1guard exists to avoid. This is the same reasoning that keptto_error/2's fallback out of application config in 1.5.0.Atom safety comes from
:reasonsrather than fromString.to_existing_atom/1. A type that declares its reasons is decoded by matching the incoming value against that declared set, so nothing from the wire reachesto_existing_atom/1at all — and a reason whose atom exists but is not declared for this type is refused, which theto_existing_atomapproach would accept and then fail on at construction. Types without declared reasons fall back toto_existing_atom/1, which is why declaring:reasonsis now worth doing on anything that crosses a boundary.A decoded error is a faithful classification, not a faithful reconstruction, and the docs say so plainly:
:kind,http_status/1,severity/1andretryable?/1are recomputed from the type in the receiving application and the encoded values are ignored, so the receiver's own definitions win even when the sender runs an older version;:envis alwaysnil, since it described a location in the sending process;:causeis kept as the decoded value rather than rebuilt; and context redacted on the way out stays redacted.Two deliberate limits. Aggregate types are refused rather than silently losing their members, because each member carries its type only as a name — decode members individually and rebuild with
new/1. And context keys come back as strings by default, because:contextholds arbitrary data and converting it is where an atom-exhaustion risk would live;keys: :existing_atomsconverts the keys that already exist, recursively and best-effort, leaving unknown ones as strings.This completes #48, whose classification half shipped in 1.6.0.
Changed
Errata.log/2andErrata.report/2now include:http_statusin their metadata, alongside the:kind,:reason,:error_type,:code,:severityand:retryablekeys that were already there. A telemetry handler can now tag on the same classification a boundary branches on — a 5xx-rate metric, for instance — without re-deriving the status from:kind.1.6.0 put all five classifications in
to_map/1but left the metadata with four, and that asymmetry was documented as deliberate on the grounds that a log line and a telemetry event are not HTTP responses. That reasoning is still true as far as it goes, but it did not survive the comparison::retryableis derived from:kindin exactly the same way and has always been in the metadata, so "derived, and only meaningful in some contexts" was never the line being drawn. What was left was an omission that had to be explained everywhere the key list appears, which costs more over time than a key some handlers ignore.Additive: metadata is a map (telemetry) and a keyword list (Logger), so existing handlers and formatters are unaffected unless they assert on the exact key set. Types that compute
http_status/1from:reasonor:contexthave that computed value in the metadata, since this dispatches through the same overridable function ascode/1,severity/1andretryable?/1already do.
[1.6.0] - 2026-08-19
Added
The error's classification now travels with it through
Errata.to_map/1, and therefore through theJason.Encoder/JSON.Encoderimplementations (#48). The serialized form gains four keys:kind,http_status,severity, andretryable.Errata's premise is that a boundary can ask any error what status to return, how loudly to log it, and whether retrying is worth attempting. That held only while the error struct was in hand: the moment it was serialized — an API response, a job payload, a message on a queue — the answers were gone, because computing them requires the error's module. Of the five classifications, only
codecrossed the wire. A receiving service had to re-derive the rest from the module name, which the docs correctly tell people not to match on, since it is an implementation detail that moves when the module moves.{ "error_type": "MyApp.Http.RequestFailed", "reason": "timeout", "kind": "infrastructure", "http_status": 503, "severity": "error", "retryable": true }The four keys are computed through the same overridable functions as the accessors, so a type that derives its status or retryability from
:reasonserializes what it actually computed rather than a default. A wrapped:causeand the members of an aggregate serialize through the sameto_map/1, so each carries its own classification instead of inheriting the outer error's.This is deliberately the classification half of #48 and not the deserialization half. Putting the answers on the wire serves a consumer that does not hold the error's module — another service, or a program not written in Elixir — and needs no atom-safety or module-resolution machinery to do it. A
from_map/2that reconstructs the struct serves the opposite case, where the receiving VM already has the module compiled and could recompute the classification anyway; it remains open, now with the cheaper half no longer blocking on it.Additive under SemVer: map patterns are open, so existing matches on
to_map/1still hold. Only an assertion of exact map equality would need updating.
[1.5.0] - 2026-08-18
Added
Errata.to_error/2andErrata.UnknownError(#46), for the errors an application did not define:{:error, :timeout}from a client library, anEcto.Changeset, aDBConnection.ConnectionError. Errata's boundary accessors are strict on purpose —Errata.http_status(:timeout)raises rather than guessing a500— so a fallback controller had one uniform clause and a hand-written one for everything else.Errata.to_error/2is total, and returns an Errata error unchanged so it is safe to apply to a value that may already be normalized:Errata.to_error(:timeout) # an Errata.UnknownError, reason: :timeout, cause: :timeout Errata.to_error(existing_error) # existing_error, unchangedErrata.UnknownErroris the default target, and the first concrete error type Errata itself ships. It is an ordinary:generalerror — a500, not retryable — with the original value kept as its:cause, soroot_cause/1andformat_chain/1still reach it. Passfallback: MyApp.UnexpectedErrorto land in an application's own catch-all instead. There is deliberately no application config for this: a global setting would be the central registry that Errata's structuralis_error/1guard exists to avoid, and it would mean a library callingto_error/1minted the application's error type.This classifies nothing on its own, and is not meant to. A
500is right for a genuinely unknown value and wrong for a changeset (a422) or a connection timeout (a retryable503), soto_error/1is documented as the base case beneath an application's own dispatch function rather than as a replacement for one:defmodule MyApp.Errors do def to_error(%Ecto.Changeset{} = changeset), do: MyApp.ValidationFailed.new(reason: :invalid, cause: changeset) def to_error(other), do: Errata.to_error(other) endAn
Errata.Convertibleprotocol was built for this and then cut before release. Every implementation would have been written by the same application that callsto_error/1— neither Ecto nor Finch is going to depend on Errata to write one, and a library that already uses Errata returns Errata errors — so the open-extension property that justifies a protocol never came into play, while its constraints (one implementation per type, globally, forever) did. Function clauses are the simpler tool when one party owns both sides, and they let two boundaries classify the same value differently. A protocol can be added later without breaking anything, which is the reason to wait rather than guess.to_error/2is a plain function rather than a macro, unlikewrap/3. That makes it capturable (&Errata.to_error/1) at the cost of leaving:envnil — which is the honest result anyway, since normalization happens in a generic boundary function whose location says nothing about where the failure came from.Two details worth knowing: an atom becomes the
:reasonas well as the cause, but only when the target type would accept it, since deriving a reason that a type's:reasonslist rejects would turn the call that exists to stop unknown values escaping into a raise. And{:error, reason}tuples are not unwrapped, since a value that legitimately is a two-tuple cannot be told apart from one that means "error" — match the tuple at the call site instead.Errata.reason/1,Errata.context/1, andErrata.kind/1(#39), completing an accessor set that already hadcode/1,severity/1,http_status/1,retryable?/1,cause/1, anddisplay_message/1.context/1returns%{}rather thannilfor an error created without context, so calling code can treat the result as a map unconditionally, and it returns the unredacted context — redaction applies to what Errata serializes and emits, not to the error in your own hands.These are also the answer to the type-checker interaction the README documented. Measured on Elixir 1.20, the picture is narrower than the issue assumed: field access after a structural guard (
{:error, e} when Errata.is_error(e) -> e.reason) is warning-free, and the one shape that still warns — a variable bound by a barerescue e ->— warns for any exception, not just an Errata one (e.messageon a plainRuntimeErrorwarns identically). So this is ordinary Elixir behaviour rather than something Errata does to you, and the accessors are a plain function call that sidesteps it.The README's info box has been rewritten accordingly, and its
Map.fetch!/2advice dropped — that workaround is not needed. Structural-guard field access is verified warning-free across the whole supported range, 1.15 through 1.20; the bare-rescuewarning appears from 1.17, when the type checker landed. The compile-time behaviour is now pinned by tests, so a future Elixir that changes it will say so.Aggregate errors (#36), for the "several things went wrong at once" shape that validation produces. A type declared
aggregate: truegains an:errorsfield holding member errors:defmodule MyApp.Orders.ValidationFailed do use Errata.DomainError, aggregate: true end ValidationFailed.new(errors: [email_error, age_error])The alternative was modelling it as one error with a list of maps in
:context, which throws away everything the library is for — each sub-failure loses its type, code, HTTP status, severity, and retryability and becomes inert data. An aggregate keeps them as errors: members serialize throughto_map/1and the JSON encoders with their own types and codes, and with their own redaction rules applied.The aggregate is itself an ordinary Errata error, so
is_error/1, raising,{:error, _}tuples, and boundary code all keep working.Errata.errors/1reaches the members and returns[]for an ordinary error, so callers never branch on whether they hold an aggregate;Errata.aggregate?/1asks about the type.The design work was the merge rules, and the three deliberately differ:
severity/1— the most severe member. Severities are totally ordered, so the maximum is unambiguous, and it is what a log level should be.retryable?/1— retryable only if every member is. Retrying helps only if all of it could succeed next time; one permanent failure makes the retry pointless.http_status/1— the members' status if they agree, otherwise the aggregate's own. There is no meaningful maximum over status codes, so picking a "highest" would be arbitrary; unanimity is the only member-derived answer that is never wrong.
An empty aggregate falls back to its own declared values, and each rule stays overridable per type. Members must themselves be Errata errors — a bare map cannot answer those three questions, so anything else raises
ArgumentErrorat construction. SeeErrata.Aggregate.Redaction of sensitive values in error context (#35). Errata encourages capturing arbitrary metadata in
:contextand then ships it outward —to_map/1and the JSON encoding,Errata.log/2as Logger metadata,Errata.report/2as telemetry metadata. There was no way to keep a value out of that path, socontext: %{params: params}put a password in the log aggregator. Unlike the other items on the list this was a safety gap rather than a missing feature: the default behavior was the unsafe one and nothing in the docs said so.A
:redactoption declares a type's sensitive keys:use Errata.DomainError, redact: [:password, :token]Redaction is recursive and matches atom and binary keys alike. This is the point rather than a bonus: the common leak is not
%{password: pw}but a params map captured wholesale, where the sensitive key is nested and has a string key.It applies at the serialization seam, not at creation, so the struct you hold keeps the real values for local debugging. Only what Errata emits is redacted.
config :errata, redact: [...]sets a global floor for every error type. The default is[]— nothing changes shape until an application opts in.The generated
redact_context/1is overridable for rules a key list cannot express, and every serialization seam dispatches through it, so an override applies to all of them rather than the one its author was looking at.Errata.Redactionis public, so custom overrides can reuse the recursive walk.
Added
Generated error types now have an overridable
display_message/1function (#45), so a type can compute its user-facing message from its:reasonor:contextrather than being limited to a static:default_message:defmodule MyApp.Orders.OrderNotFound do use Errata.DomainError, default_message: "the requested order does not exist" def display_message(%{context: %{order_id: id}}), do: "order #{id} does not exist" def display_message(error), do: error.message endErrata.display_message/1andErrata.to_map/1(and therefore the JSON encoding) now dispatch through it, so an override reaches every place a user-facing message is read. This bringsdisplay_message/1in line withhttp_status/1,code/1,severity/1, andretryable?/1, which were already generated-overridable-and-delegated; it was the only one reading the struct field directly. The default returns the:messagefield unchanged, so behavior is unchanged for types that do not override it.
Documentation
The README is split into a short front page plus guides (#40). It was 955 lines, and everything added since 1.0 had landed in one linear page — the cumulative effect overstated what a reader has to learn to start. The front page now covers what Errata is, the quick start, defining error types, creating and raising them, and an index; four new guides under
guides/cover the rest:guides/handling-errors.md— the guards,use Errata, values vs. rescuing.guides/boundaries.md— HTTP status, external codes, severity and retryability, and rendering an error for a user.guides/wrapping-errors.md—wrap/2and cause chains, context enrichment, and aggregates.guides/observability.md—log/2,report/2, the telemetry contract, and redaction.
guides/design.mdgains the "type vs. reason" and "Why Errata?" material alongside the:kindguidance it already held. Doctest coverage moved with the content rather than being lost: the 23 README doctests are now 16 on the front page plus 7 in the guides, run bydoctest_file/1in the newtest/guides_test.exs.The shared doctest fixtures (
MyApp.Orders.*) moved from the top oftest/errata_test.exsintotest/support/my_app.ex, so that any single test file needing them can be run on its own. This also makeselixirc_paths(:test)'s long-standingtest/supportentry point at a directory that exists.A "Dynamic messages" section in the README (#23) showing how to compute a user-facing message from an error's
:reasonor:contextby overridingdisplay_message/1, rather than building the string by hand at every call site. This is the answer to message templating: a plain function and pattern matching, with no template syntax to learn and no missing-key failure mode. The examples are doctests, including the one showing that the override deliberately does not change the developer message thatException.message/1and logs use. The:default_messageoption docs now point at it.
Fixed
to_string/1(theString.Charsimplementation) now respects an overriddenmessage/1(#45). It called the internal message formatter directly, so a type that overrodemessage/1got its custom rendering fromException.message/1,raise, andErrata.log/2, but silently got the default fromto_string/1— despite the two being documented as the same developer-oriented message.to_string/1andException.message/1now always agree.
Documentation
Errata.create/2is now documented as the recommended way to create an error (#37). It captures the same:envas the per-modulecreate/1macro, but because it takes the error type as an argument, a singleuse Erratacovers every error type a module creates — the per-typerequirethatcreate/1needs is never required. The README and theErrata.Errormoduledoc now lead with it.Errata.Error.create/1documents the cost of capturing the environment: on the order of a microsecond per error, and flat with respect to stack depth, since the VM already caps the captured stacktrace at 8 frames. Explicitly not a reason to reach forErrata.Error.new/1.Errata.Error.new/1says what it is actually for, rather than reading as a trap: the cases a macro cannot serve — dynamic invocation viaapply/3, capturing as&SomeError.new/1— plus tests and fixtures, whereenv: nilkeeps error structs easy to compare.A new "Design notes" guide (
guides/design.md, #38) covering the:kindtaxonomy from the user's side: what each kind actually decides, how to choose one, where external-service errors belong, and how to opt out of the taxonomy entirely by defining every type with the baseErrata.Error. Two points it makes plainly that the reference docs did not:kindsupplies defaults forhttp_status/1andretryable?/1only —severity/1andcode/1do not derive from it — and thehttp_status/1default is a starting point that domain errors often override, while theretryable?/1default is usually right. The guide's examples are pinned bytest/errata/design_guide_test.exs, since they are module definitions rather than doctests.
Changed
- The
error/0,domain_error/0, andinfrastructure_error/0types now carry anoptional(:errors)key, so that code matching on an aggregate's members type-checks. This is additive: an ordinary error still matches, and no existing spec becomes invalid. Errata.report/2telemetry metadata now carries the:errorstruct with its context redacted, not just the separate:contextkey (#35). Leaving the raw struct there would have made redaction pointless in the case it exists for — a handler forwardingmetadata.errorto an external service would ship the unredacted context. The struct is otherwise untouched: same type, same reason, still pattern-matchable and re-raisable. This only affects error types that declare:redactkeys or applications that set the global config; with neither, metadata is unchanged.
[1.4.0] - 2026-07-31
Added
- Stable external error codes. An error type can declare a
:code(such as"ORDER_NOT_FOUND") that is independent of its Elixir module name, giving external consumers — API clients, i18n catalogs, support tooling — an identifier that survives renaming or moving the module. Retrieve it withErrata.code/1or the generated per-modulecode/1, which is overridable so a type can derive a code from the error's:reasonor:context. Codes are opt-in with no default: a type that does not declare one returnsnil, since deriving a code from the module name would reintroduce the coupling the option exists to break. (#22) - Severity and retryability classification on error types. (#24)
Errata.severity/1returns an error's severity as aLoggerlevel, set per type with the:severityoption. It defaults to:errorfor every kind, so nothing is reclassified unless a type opts in.Errata.retryable?/1returns whether an error is likely transient, set per type with the:retryableoption and defaulting off the error's kind::infrastructureerrors are retryable,:domainand:generalerrors are not. Errata provides no retry mechanism of its own — this is a classification for your own retry logic to branch on.- Both are generated as overridable per-module functions (
severity/1andretryable?/1), following the same pattern ashttp_status/1, so a type can compute either from the error's:reasonor:context. Neither adds a field to the error struct or to theto_map/1/ JSON shape.
Changed
Errata.to_map/1(and therefore the JSON encoding) now includes acodekey, which isnullfor error types that do not declare a:code. This is additive to the serialized shape — the key is always present, consistent with the existingmessage,cause, andenvkeys, which are likewise emitted when empty. Consumers that ignore unknown keys are unaffected. (#22)Errata.log/2now logs at the error'sseverity/1when no level is given, andErrata.report/2withlog: truedoes the same. Since severity is:errorunless a type sets one, this is backward compatible for existing error types. (#24):code,:severity, and:retryableare now included in the metadata emitted byErrata.log/2(as Logger metadata) andErrata.report/2(as top-level[:errata, :error]telemetry metadata), so handlers can route or alert on them. (#22, #24)
Fixed
- The
Errata.error/0type declaredenv: Errata.Env.t(), but an error created withnew/1has no environment. It is nowErrata.Env.t() | nil, matchingErrata.domain_error/0andErrata.infrastructure_error/0and the actual behavior.
[1.3.0] - 2026-06-04
Added
use Errata— a convenience macro for modules that handle or create Errata errors. It imports the three guards (is_error/1,is_domain_error/1,is_infrastructure_error/1) so they can be used unqualified inwhenclauses and function heads, and (becauseimportimpliesrequire) makes theErrata.create/2andErrata.wrap/3macros callable. Only the guards are imported; the rest of the API stays qualified. This is distinct fromuse Errata.Error, which defines a new error type.
[1.2.0] - 2026-06-04
Added
Errata.wrap/2andErrata.wrap/3macros, which wrap a cause in an error of any type while capturing the current__ENV__and stacktrace — the convenience counterpart to the per-modulewrap/2macro, mirroringErrata.create/2. This lets a module wrap causes for several error types without a separaterequirefor each one.
[1.1.0] - 2026-06-03
Added
- Native JSON support: on Elixir 1.18 and later, every error type now implements
the built-in
JSON.Encoderprotocol, soJSON.encode!(error)works with no third-party dependencies. The built-in and Jason backends produce the same JSON shape. (#30)
Changed
jasonis now an optional dependency. Projects that have Jason continue to get a generatedJason.Encoderimplementation exactly as before; projects on Elixir 1.18+ that don't use Jason can now drop it and rely on the built-inJSONencoder. This is backward compatible — anyone who depends onJason.encode!(error)already has Jason in their own dependencies. (#30)
Upgrading
- If your project calls
Jasondirectly but relied on Errata to pull it in transitively, add{:jason, "~> 1.4"}to your own dependencies, since Errata no longer forces it into your dependency tree. On Elixir 1.18+ you can instead use the built-inJSONmodule and drop the Jason dependency entirely.
[1.0.0] - 2026-06-03
First stable release. As of 1.0.0 the public API — the error struct shape, the
Errata guards and helper functions, the generated Errata.Error callbacks, and
the to_map/1 / JSON and [:errata, :error] telemetry shapes — is covered by
Semantic Versioning.
Added
- Context enrichment:
Errata.put_context/3andErrata.merge_context/2add to an error's:contextas it propagates, so intermediate layers can attach context the creation site did not have without rebuilding the struct. (#18) - Declared reasons: error types can now enumerate their valid reasons with the
:reasonsoption (use Errata.DomainError, reasons: [...]). Creating an error with a reason outside the declared set raises anArgumentError(anilreason is always allowed); a:default_reason, if given, must be one of the declared reasons; and areason/0type enumerating them is generated for the docs. (#20) - Error reporting:
Errata.log/2logs an error at a given level with itsreason,kind,context, and origin attached as structured Logger metadata;Errata.report/2emits a[:errata, :error]telemetry event (and optionally logs), providing a vendor-neutral seam for forwarding errors to Sentry, metrics, etc. via a telemetry handler in your application. Adds atelemetry ~> 1.0dependency. (#19) - HTTP status mapping: each error type now has a generated, overridable
http_status/1function (and a matchingErrata.http_status/1) that defaults off the error's kind (:domain→422,:infrastructure→503,:general→500). Set a specific status with the:http_statusoption, or override the function to compute one from the error. No web-framework dependency is added. (#21)
[0.10.0] - 2026-06-02
Added
- Error wrapping (chaining): error types can now carry a
:cause— the original error, exception, or value that led to them — without losing the context of the underlying failure.- A generated
wrap/1,2macro on each error module wraps a caught error as the:causeof a new error, capturing the current__ENV__(likecreate/1) and, when givenstacktrace: __STACKTRACE__, the original error's stacktrace. new/1,create/1, andraise/2now also accept a:causeparam.- The cause is stored as an
Errata.Causestruct (kind/value/stacktrace). Errata.cause/1returns the immediate cause;Errata.root_cause/1walks the chain to the deepest cause;Errata.format_chain/1renders the fullCaused by:chain for logging.to_map/1(and JSON) now include the cause, recursing into wrapped Errata errors and rendering standard exceptions by type and message.
- A generated
[0.9.0] - 2026-06-02
Added
Errata.create/2macro to create an error of any type while capturing the current env, without a separaterequirefor each error module. (#4)Errata.to_map/1to convert any Errata error to a plain, JSON-encodable map without needing to know the error's specific module. (#5)Errata.display_message/1to retrieve the bare, human-readable:messageof an error (without the:reasonsuffix thatException.message/1appends), for rendering errors to end users. (#7)
Changed
- Breaking:
new/1,create/1, andraise/2now raise anArgumentErrorwhen given unrecognized param keys instead of silently ignoring them. Only:message,:reason, and:contextare accepted. Callers that previously relied on extra keys being dropped will need to remove them. (#3)
Fixed
- Serialized error maps (
to_map/1) and their JSON form no longer leak theElixir.prefix on module names:error_typeandenv.moduleare now rendered as e.g."MyApp.Foo"(as strings rather than raw atoms), andenv.file_lineno longer includes a trailing colon. (#6)