# `Mob.Defect.Capsule`
[🔗](https://github.com/genericjam/mob/blob/master/lib/mob/defect/capsule.ex#L1)

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 `id`s 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_us`
* `build.*`, `device.*`
* `evidence` beyond what the caller put in `fingerprint_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.

# `build_info`

```elixir
@type build_info() :: %{
  mob: String.t() | nil,
  mob_dev: String.t() | nil,
  app: String.t() | nil,
  commit: String.t() | nil,
  dirty: boolean() | nil,
  otp: String.t() | nil,
  elixir: String.t() | nil,
  loaded_md5: map()
}
```

# `device_info`

```elixir
@type device_info() :: %{
  platform: :ios | :android | :host,
  os: String.t() | nil,
  model: String.t() | nil,
  simulator: boolean() | nil,
  locale: String.t() | nil,
  scale: number() | nil
}
```

# `kind`

```elixir
@type kind() ::
  :native_crash
  | :beam_crash
  | :anr
  | :oom
  | :user_kill
  | :invariant
  | :divergence
  | :deploy_mismatch
  | :perf_regression
```

# `owner`

```elixir
@type owner() ::
  :mob | :mob_dev | :mob_new | {:plugin, atom() | String.t()} | :app | :unknown
```

# `redaction`

```elixir
@type redaction() :: :applied | :none
```

# `repro`

```elixir
@type repro() :: %{available: boolean(), minimized: boolean(), steps: [term()]}
```

# `severity`

```elixir
@type severity() :: :fatal | :critical | :warning | :info
```

# `t`

```elixir
@type t() :: %Mob.Defect.Capsule{
  build: build_info(),
  detected_at: String.t(),
  device: device_info(),
  evidence: map(),
  fingerprint: String.t(),
  id: String.t(),
  kind: kind(),
  owner: owner(),
  redaction: redaction(),
  repro: repro(),
  schema: String.t(),
  severity: severity()
}
```

# `build_info`

```elixir
@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`.

# `describe`

```elixir
@spec describe(t()) :: String.t()
```

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.

# `device_info`

```elixir
@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.

# `fingerprint`

```elixir
@spec fingerprint(kind(), owner(), map()) :: String.t()
```

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.

# `new`

```elixir
@spec new(keyword() | map()) :: t()
```

Build a capsule.

Required:
  * `:kind` — see `t:kind/0`
  * `:owner` — see `t:owner/0`
  * `:severity` — see `t:severity/0`
  * `:fingerprint_key` — the map that identifies the defect *class*. Kept
    separately from `:evidence` so 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`. `:none` is 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 for `detected_at`. Defaults to `System.system_time(:millisecond)`;
    tests use this to make timestamps deterministic.

# `to_json`

```elixir
@spec to_json(t()) :: map()
```

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.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
