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 layout usage_rules syncs from. A consumer can pull it into their AGENTS.md with mix usage_rules.sync, or read it at deps/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.Chars and JSON protocol implementations are consolidated when your project compiles — then create/2 vs new/1, the cause chain, and the boundary rules. Two things it states explicitly that the guides leave implicit: every accessor raises ArgumentError on a non-Errata value, which matters because the boundary where you ask is the boundary where foreign error shapes arrive; and to_error({:error, :timeout}) does not unwrap the tuple.

Fixed

  • guides is now included in the package. The files: list was ["lib", "mix.exs", "README.md", "LICENSE", "CHANGELOG.md"] while the README links into guides/ 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, since mix hex.publish builds docs from the working tree.

Deprecated

  • Errata.root_cause/1. Use root_error/1, or cause/1 on 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/1 is 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 problem root_error/1 was 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 exactly cause(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/1 was kept on the grounds that cause(root_error(e)) returns nil for 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, nil there means "no foreign original", which is unsurprising.

    The guides now lead with root_error/1 and state the chain model in one sentence.

[1.8.0] - 2026-08-24

Added

  • Errata.to_map/2 takes an :only or :except projection, 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 still

    to_map/1 has 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 :env in the structure rather than only the outermost one; leaving members' :env in 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 :env to drop and an :only projection would mangle it. Keys are validated against the set to_map/1 can produce, so except: [:envv] raises rather than quietly selecting nothing.

    The encoder protocols are unchanged and still emit the full map. Jason.Encoder takes 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.md now says so under a warning admonition, and shows selecting fields explicitly instead.

  • The cause chain now reaches Errata.log/2 and Errata.report/2 metadata, under two new keys.

    Before this, a Logger backend could not recover the cause at all. log/2 logs Exception.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 — and log_metadata/1 carried no cause key and no error struct. The single most useful operator fact was absent from the library's own logging path, and guides/observability.md never mentioned the cause chain at all. Telemetry fared better only by accident: its metadata includes the :error struct, so a handler could dig the chain out itself.

    Two keys, for two kinds of consumer:

    • cause — the same nested map to_map/1 emits, so a structured log formatter or a telemetry handler gets every level of the chain with its own code, context and 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 nil for an error with no cause. caused_by is deliberately not named root_cause: Errata.root_cause/1 returns 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/2 is used for exceptions and inspect/1 for 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/1 only 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: :econnrefused has 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/1 diagnoses what failed, and is what a developer wants in a log; root_error/1 is 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/1 always returns an Errata error, so a caller never has to check what it got, where root_cause/1 may return either and leaves that to the caller.

    They are not two views of the same fact. wrap/2 keeps the cause in :cause and copies nothing out of it — a wrapped error's :reason and :context are 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-clause case — with a clause-order trap, since an Errata error is also an exception — and an inspect/1 fallback that would have shown a person the string ":timeout". Written against root_error/1 it is a pipeline with no branching:

    def user_message(value) do
      value |> Errata.to_error() |> Errata.root_error() |> Errata.display_message()
    end

    It is also more correct. The old version surfaced Exception.message/1 from 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's display_message/1 is 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_message is optional and the README presents the bare one-line definition as the normal starting point, so a type rendering as nil through display_message/1, to_map/1, and the JSON encoding is easy to reach — and "error": null in 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_message and a per-error :message both win — and it is read at runtime, mirroring config :errata, redact:, which is the closest existing analogue: a global floor that individual types refine. Because to_map/1 dispatches through the generated display_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/1 now returns the error itself when the error has no cause, instead of nil (#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, unchanged

    The nil was doing a job cause/1 already does, and doing it in place of its own. Three things inside the library pointed the same way:

    • format_chain/1 already includes the error itself — it renders an uncaused error as a one-element chain. root_cause/1 was the one function that modelled the chain as causes-only.
    • errors/1 already 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/1 returning nil was the same decision made the other way.
    • root_cause/1 could not be implemented without the workaround it asked users to write. Its recursive clause read root_cause(value) || value, because the recursive call returned nil for an uncaused inner error. That || is now gone; the recursion is total.

    A dogfooding application hand-wrote its own root_cause/1 rather 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, since error || error is error; the || error can now be deleted. What breaks, and breaks quietly, is code using the nil to 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 -> ...
    end

    Grep for root_cause near if, case or nil. In every such place the replacement is Errata.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.file reads lib/my_app/orders.ex instead 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. The Errata.Env struct 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 use options are now a compile-time ArgumentError instead of being silently ignored (#62).
    defmodule 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].
    Previously define/3 validated the values of :reasons and :aggregate and never looked at the key set, so htp_status: 404 produced a type with the default 422 and defualt_message: produced message: nil — both plausible enough that nothing downstream looks broken. This is the same defect class as #3, which was fixed for new/1 and create/1 params but not at the use site, which is the worse of the two: a param typo is caught the first time that line runs, while a use option is written once and the misconfiguration is permanent.It raises rather than warns, for consistency with the new/1 param check and with :reasons/:aggregate validation, 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:
    • :kind is now rejected by use Errata.DomainError and use Errata.InfrastructureError, which set the kind themselves and previously ignored it. The message points at use Errata.Error.
    • An invalid :kind value (use Errata.Error, kind: :bogus) raises ArgumentError naming 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) == :x produces a two-line diff naming the field, where nulling :env produces ~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 :env stacktrace swamps the output.

    Also covers where fixture types must be defined and why; the seam at which redaction has to be asserted (error.context still holds the plaintext — checking it first is how redaction looks broken); the two things :redact structurally cannot protect, with a refute_leaks/2 recipe; the :telemetry_test idiom, which needs no new dependency; that capture_log/1 needs metadata: :all before the metadata log/2 exists for appears; and that assert_raise matches the developer message rather than the display message.

    Every example is pinned by test/errata/testing_guide_test.exs, following design_guide_test.exs — the guide's examples are ExUnit assertions rather than iex> 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-line root_cause/1 loop and shipped it without noticing Errata.root_cause/1 exists — 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/1 returns nil when there is no cause, which is the truthful answer to the question it asks but rarely the one a call site wants, so Errata.root_cause(error) || error is 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 a with chain returned has to normalize first; to_error/2 and root_cause/1 compose, and the guides never paired them.

    root_cause/1 was 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 needs require Errata even to call them fully qualified, and every other guide example picks that up invisibly via use 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 :env exposure above makes easy to get wrong.

    The to_map/2 projection 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 own code; and :errors is 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/3 and Errata.from_map!/3 (#48), the counterpart to to_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)  #=> true

    Accepts the map from to_map/1 directly or the result of decoding its JSON — string and atom keys both work. from_map/3 returns {:ok, error} | {:error, reason}, since malformed input is an expected condition where this gets called; from_map!/3 returns 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 structural is_error/1 guard exists to avoid. This is the same reasoning that kept to_error/2's fallback out of application config in 1.5.0.

    Atom safety comes from :reasons rather than from String.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 reaches to_existing_atom/1 at all — and a reason whose atom exists but is not declared for this type is refused, which the to_existing_atom approach would accept and then fail on at construction. Types without declared reasons fall back to to_existing_atom/1, which is why declaring :reasons is 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/1 and retryable?/1 are 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; :env is always nil, since it described a location in the sending process; :cause is 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 :context holds arbitrary data and converting it is where an atom-exhaustion risk would live; keys: :existing_atoms converts 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/2 and Errata.report/2 now include :http_status in their metadata, alongside the :kind, :reason, :error_type, :code, :severity and :retryable keys 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/1 but 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: :retryable is derived from :kind in 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/1 from :reason or :context have that computed value in the metadata, since this dispatches through the same overridable function as code/1, severity/1 and retryable?/1 already do.

[1.6.0] - 2026-08-19

Added

  • The error's classification now travels with it through Errata.to_map/1, and therefore through the Jason.Encoder / JSON.Encoder implementations (#48). The serialized form gains four keys: kind, http_status, severity, and retryable.

    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 code crossed 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 :reason serializes what it actually computed rather than a default. A wrapped :cause and the members of an aggregate serialize through the same to_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/2 that 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/1 still hold. Only an assertion of exact map equality would need updating.

[1.5.0] - 2026-08-18

Added

  • Errata.to_error/2 and Errata.UnknownError (#46), for the errors an application did not define: {:error, :timeout} from a client library, an Ecto.Changeset, a DBConnection.ConnectionError. Errata's boundary accessors are strict on purpose — Errata.http_status(:timeout) raises rather than guessing a 500 — so a fallback controller had one uniform clause and a hand-written one for everything else.

    Errata.to_error/2 is 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, unchanged

    Errata.UnknownError is the default target, and the first concrete error type Errata itself ships. It is an ordinary :general error — a 500, not retryable — with the original value kept as its :cause, so root_cause/1 and format_chain/1 still reach it. Pass fallback: MyApp.UnexpectedError to 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 structural is_error/1 guard exists to avoid, and it would mean a library calling to_error/1 minted the application's error type.

    This classifies nothing on its own, and is not meant to. A 500 is right for a genuinely unknown value and wrong for a changeset (a 422) or a connection timeout (a retryable 503), so to_error/1 is 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)
    end

    An Errata.Convertible protocol was built for this and then cut before release. Every implementation would have been written by the same application that calls to_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/2 is a plain function rather than a macro, unlike wrap/3. That makes it capturable (&Errata.to_error/1) at the cost of leaving :env nil — 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 :reason as well as the cause, but only when the target type would accept it, since deriving a reason that a type's :reasons list 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, and Errata.kind/1 (#39), completing an accessor set that already had code/1, severity/1, http_status/1, retryable?/1, cause/1, and display_message/1. context/1 returns %{} rather than nil for 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 bare rescue e -> — warns for any exception, not just an Errata one (e.message on a plain RuntimeError warns 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!/2 advice 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-rescue warning 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: true gains an :errors field 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 through to_map/1 and 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/1 reaches the members and returns [] for an ordinary error, so callers never branch on whether they hold an aggregate; Errata.aggregate?/1 asks 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 ArgumentError at construction. See Errata.Aggregate.

  • Redaction of sensitive values in error context (#35). Errata encourages capturing arbitrary metadata in :context and then ships it outward — to_map/1 and the JSON encoding, Errata.log/2 as Logger metadata, Errata.report/2 as telemetry metadata. There was no way to keep a value out of that path, so context: %{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 :redact option 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/1 is 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.Redaction is public, so custom overrides can reuse the recursive walk.

Added

  • Generated error types now have an overridable display_message/1 function (#45), so a type can compute its user-facing message from its :reason or :context rather 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
    end

    Errata.display_message/1 and Errata.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 brings display_message/1 in line with http_status/1, code/1, severity/1, and retryable?/1, which were already generated-overridable-and-delegated; it was the only one reading the struct field directly. The default returns the :message field 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.mdwrap/2 and cause chains, context enrichment, and aggregates.
    • guides/observability.mdlog/2, report/2, the telemetry contract, and redaction.

    guides/design.md gains the "type vs. reason" and "Why Errata?" material alongside the :kind guidance 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 by doctest_file/1 in the new test/guides_test.exs.

  • The shared doctest fixtures (MyApp.Orders.*) moved from the top of test/errata_test.exs into test/support/my_app.ex, so that any single test file needing them can be run on its own. This also makes elixirc_paths(:test)'s long-standing test/support entry 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 :reason or :context by overriding display_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 that Exception.message/1 and logs use. The :default_message option docs now point at it.

Fixed

Documentation

  • Errata.create/2 is now documented as the recommended way to create an error (#37). It captures the same :env as the per-module create/1 macro, but because it takes the error type as an argument, a single use Errata covers every error type a module creates — the per-type require that create/1 needs is never required. The README and the Errata.Error moduledoc now lead with it.

  • Errata.Error.create/1 documents 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 for Errata.Error.new/1.

  • Errata.Error.new/1 says what it is actually for, rather than reading as a trap: the cases a macro cannot serve — dynamic invocation via apply/3, capturing as &SomeError.new/1 — plus tests and fixtures, where env: nil keeps error structs easy to compare.

  • A new "Design notes" guide (guides/design.md, #38) covering the :kind taxonomy 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 base Errata.Error. Two points it makes plainly that the reference docs did not: kind supplies defaults for http_status/1 and retryable?/1 only — severity/1 and code/1 do not derive from it — and the http_status/1 default is a starting point that domain errors often override, while the retryable?/1 default is usually right. The guide's examples are pinned by test/errata/design_guide_test.exs, since they are module definitions rather than doctests.

Changed

  • The error/0, domain_error/0, and infrastructure_error/0 types now carry an optional(: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/2 telemetry metadata now carries the :error struct with its context redacted, not just the separate :context key (#35). Leaving the raw struct there would have made redaction pointless in the case it exists for — a handler forwarding metadata.error to 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 :redact keys 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 with Errata.code/1 or the generated per-module code/1, which is overridable so a type can derive a code from the error's :reason or :context. Codes are opt-in with no default: a type that does not declare one returns nil, 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/1 returns an error's severity as a Logger level, set per type with the :severity option. It defaults to :error for every kind, so nothing is reclassified unless a type opts in.
    • Errata.retryable?/1 returns whether an error is likely transient, set per type with the :retryable option and defaulting off the error's kind: :infrastructure errors are retryable, :domain and :general errors 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/1 and retryable?/1), following the same pattern as http_status/1, so a type can compute either from the error's :reason or :context. Neither adds a field to the error struct or to the to_map/1 / JSON shape.

Changed

  • Errata.to_map/1 (and therefore the JSON encoding) now includes a code key, which is null for error types that do not declare a :code. This is additive to the serialized shape — the key is always present, consistent with the existing message, cause, and env keys, which are likewise emitted when empty. Consumers that ignore unknown keys are unaffected. (#22)
  • Errata.log/2 now logs at the error's severity/1 when no level is given, and Errata.report/2 with log: true does the same. Since severity is :error unless a type sets one, this is backward compatible for existing error types. (#24)
  • :code, :severity, and :retryable are now included in the metadata emitted by Errata.log/2 (as Logger metadata) and Errata.report/2 (as top-level [:errata, :error] telemetry metadata), so handlers can route or alert on them. (#22, #24)

Fixed

[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 in when clauses and function heads, and (because import implies require) makes the Errata.create/2 and Errata.wrap/3 macros callable. Only the guards are imported; the rest of the API stays qualified. This is distinct from use Errata.Error, which defines a new error type.

[1.2.0] - 2026-06-04

Added

  • Errata.wrap/2 and Errata.wrap/3 macros, which wrap a cause in an error of any type while capturing the current __ENV__ and stacktrace — the convenience counterpart to the per-module wrap/2 macro, mirroring Errata.create/2. This lets a module wrap causes for several error types without a separate require for 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.Encoder protocol, so JSON.encode!(error) works with no third-party dependencies. The built-in and Jason backends produce the same JSON shape. (#30)

Changed

  • jason is now an optional dependency. Projects that have Jason continue to get a generated Jason.Encoder implementation exactly as before; projects on Elixir 1.18+ that don't use Jason can now drop it and rely on the built-in JSON encoder. This is backward compatible — anyone who depends on Jason.encode!(error) already has Jason in their own dependencies. (#30)

Upgrading

  • If your project calls Jason directly 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-in JSON module 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/3 and Errata.merge_context/2 add to an error's :context as 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 :reasons option (use Errata.DomainError, reasons: [...]). Creating an error with a reason outside the declared set raises an ArgumentError (a nil reason is always allowed); a :default_reason, if given, must be one of the declared reasons; and a reason/0 type enumerating them is generated for the docs. (#20)
  • Error reporting: Errata.log/2 logs an error at a given level with its reason, kind, context, and origin attached as structured Logger metadata; Errata.report/2 emits 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 a telemetry ~> 1.0 dependency. (#19)
  • HTTP status mapping: each error type now has a generated, overridable http_status/1 function (and a matching Errata.http_status/1) that defaults off the error's kind (:domain422, :infrastructure503, :general500). Set a specific status with the :http_status option, 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,2 macro on each error module wraps a caught error as the :cause of a new error, capturing the current __ENV__ (like create/1) and, when given stacktrace: __STACKTRACE__, the original error's stacktrace.
    • new/1, create/1, and raise/2 now also accept a :cause param.
    • The cause is stored as an Errata.Cause struct (kind/value/stacktrace).
    • Errata.cause/1 returns the immediate cause; Errata.root_cause/1 walks the chain to the deepest cause; Errata.format_chain/1 renders the full Caused 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.

[0.9.0] - 2026-06-02

Added

  • Errata.create/2 macro to create an error of any type while capturing the current env, without a separate require for each error module. (#4)
  • Errata.to_map/1 to convert any Errata error to a plain, JSON-encodable map without needing to know the error's specific module. (#5)
  • Errata.display_message/1 to retrieve the bare, human-readable :message of an error (without the :reason suffix that Exception.message/1 appends), for rendering errors to end users. (#7)

Changed

  • Breaking: new/1, create/1, and raise/2 now raise an ArgumentError when given unrecognized param keys instead of silently ignoring them. Only :message, :reason, and :context are 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 the Elixir. prefix on module names: error_type and env.module are now rendered as e.g. "MyApp.Foo" (as strings rather than raw atoms), and env.file_line no longer includes a trailing colon. (#6)