A defect, in the shape a consumer can act on.
This is the format from
decisions/2026-09-04-defect-reports-are-a-shipped-feature.md. The record is
a struct in memory and mob.defect/1 JSON on the wire, and the two are the
same shape — a struct field with a snake-case atom becomes a JSON key with the
same spelling, so a reader of one can read the other without a schema
translation.
What is fixed here vs. what a caller provides
A caller — the invariant registry, the differential comparator, the crash
handler, whatever — supplies what makes this defect this defect:
kind, owner, severity, evidence, and the fingerprint inputs. The
capsule fills in what is the same across every defect: schema, id,
detected_at, build, device, redaction. That split is deliberate. A
detector that had to construct the build section itself would drift out of
step with the rest of the reports the moment mob bumped its version, and a
fingerprint the caller computed by hand would land in three different shapes.
Fingerprint
fingerprint groups occurrences into one triage item. One thousand phones
hitting the same bug produces one thousand ids and one fingerprint, and
that is what makes the report channel usable rather than a firehose.
The stable inputs are what defines the defect, and only those:
kind,owner- The caller's
fingerprint_key— a small map like%{invariant: :parked_screen_alive, screen: MyScreen}— describing the defect class, not this occurrence
The excluded inputs are what changes across occurrences of the same defect:
id,detected_at,monotonic_usbuild.*,device.*evidencebeyond what the caller put infingerprint_key
A fingerprint that included the build version would open a new triage item every release, which is what the pre-shipped defect systems in this space do wrong.
Redaction
A capsule is written to a bounded ring buffer, and from there to whatever sink the app has wired up (dev-mode: the connected agent; production: whatever the app developer configured, if anything). The write and the possibility of a sink are the same event: by the time the caller has a capsule value, redaction has already happened. Sinks receive already-safe data, per the "redaction is a precondition" clause of the decision record.
For phase 1 the discipline is pushed onto the callers: an evidence map that
arrives here is trusted to contain no application state, and the capsule
tags it redaction: :applied on that basis. The invariant registry and the
differential comparator both already promise that (see
Mob.Invariant.Violation's moduledoc and Mob.Differential's docs), so the
emit paths in phase 1 are trustworthy inputs by construction. Later phases
(native crash, ApplicationExitInfo, incident-capsule from a raised
exception) will need Mob.Agent.Receipt.summarize_error/3-style reduction,
which happens before the value reaches new/1.
A caller that provably packages no state can pass redaction: :none to
new/1, matching the schema. The default is :applied, and the default is
the safe answer.
Bounded shapes
A defect from an app in the field has to fit through a pipe an app developer
is willing to keep open. evidence and fingerprint_key are truncated
during construction: strings above 4096 bytes are
clipped and lists above 64 elements are cut, with a
tag left in place so a consumer can see clipping happened rather than treat
the visible half as the whole. The recursion depth cap protects the packager
from a maliciously deep term — a defect reporter that can be crashed by the
defect it is reporting is worse than none.
Summary
Functions
The build section, computed once per node and cached.
A one-line triage summary for a human reading a log.
The device section, computed once per node and cached.
A stable sha256 hex over the defect-defining fields only.
Build a capsule.
The JSON-shaped map for the wire.
Types
@type kind() ::
:native_crash
| :beam_crash
| :anr
| :oom
| :user_kill
| :invariant
| :divergence
| :deploy_mismatch
| :perf_regression
@type redaction() :: :applied | :none
@type severity() :: :fatal | :critical | :warning | :info
Functions
@spec build_info() :: build_info()
The build section, computed once per node and cached.
Versions do not change during a session. Application.spec/2 is cheap on
its own, but the write path pays it on every emit — the small cost adds up
under a burst of confirmed violations from a runaway teardown, and the
cached value is trivially cheaper. Same reasoning as device_info/0.
A one-line triage summary for a human reading a log.
Deliberately compact and free of any application state — everything shown here has already been through the capsule's redaction contract, so this is safe to log even when the sink is a remote agent over dist.
@spec device_info() :: device_info()
The device section, computed once per node and cached.
platform, os, model, and the fields that will populate around them do
not change during a session — the OS reports the same model on every call,
and OS version changes only across upgrades that require a relaunch. Every
emit was paying two NIF hops for those on the write path, which on a
teardown-heavy sampling point (:on_screen_stop) is real cost added to
every confirmed violation. Read once, cache in :persistent_term, done.
:persistent_term.put/2 is expensive (it triggers a global GC) but that
cost pays for reads that are the cheapest thing on the BEAM, and this is a
one-shot at first emit — a use-once, read-many pattern which is exactly the
case persistent_term is documented for.
A stable sha256 hex over the defect-defining fields only.
kind, owner, and fingerprint_key are hashed with a canonical
representation of the map (keys sorted, atoms unified with their string
form) so two callers that produce the same defect land on the same
fingerprint regardless of key insertion order. Time, id, build, device, and
extra evidence are excluded — a fingerprint is a defect class, not an
occurrence.
Exposed for tests and for callers that want to correlate a defect against
something they compute themselves. Called by new/1, so ordinary construction
does not need to call this directly.
Build a capsule.
Required:
:kind— seekind/0:owner— seeowner/0:severity— seeseverity/0:fingerprint_key— the map that identifies the defect class. Kept separately from:evidenceso the fingerprint stays stable when evidence gains fields between releases.
Optional:
:evidence— kind-specific detail. Defaults to%{}. Truncated during construction.:redaction—:applied(default) or:none.:noneis only legal for a capsule that provably carries no application state.:repro— a repro map. Defaults to%{available: false, minimized: false, steps: []}since phase 1 does not attempt reproduction.:build/:device— override the framework-populated sections (tests use this to make snapshots deterministic).:now_ms— Unix milliseconds fordetected_at. Defaults toSystem.system_time(:millisecond); tests use this to make timestamps deterministic.
The JSON-shaped map for the wire.
Structurally identical to the struct — a struct field with a snake-case atom
becomes a JSON key of the same name. Owners get their string form:
{:plugin, :foo} becomes "plugin:foo", matching the schema.
Callers that want bytes rather than a map pass this to Jason.encode!/1.
Jason is mob's only runtime dep, so a caller inside the tree can always
encode; a caller outside can too but is not obliged to.