# `Mob.Agent.Receipt`
[🔗](https://github.com/genericjam/mob/blob/master/lib/mob/agent/receipt.ex#L1)

What one action did, and which layer is answerable if it did nothing.

An agent driving a Mob app can already ask "what are the assigns now". It
cannot ask "did *my* tap cause that". The difference matters more than it
sounds: the effect detector behind `tap_xy/3` waits 300ms for a process-wide
event counter to move, so any Mob event inside that window — a timer, a scroll
notification, another agent on the same device — is indistinguishable from the
tap landing. It reports success for taps that did nothing, and on 2026-09-04 it
reported `{:error, :no_effect}` for a tap that demonstrably worked.

A receipt replaces the window with a correlation id. One action, one id,
followed from dispatch through to the committed frame.

## The stages

An action passes through the stages below, and the *first* one it fails to
reach names the layer at fault. That is the whole point of recording them
separately rather than reporting a boolean:

| Stage | Reached when | If it stops here |
|---|---|---|
| `:dispatched` | the screen received the event | — |
| `:unhandled` | *no* clause matched the event | event routing — a stale tag, a renamed event |
| `:handled` | `handle_event/3` returned | — it raised; see `:unhandled` below |
| `:assigns_changed` | the socket's assigns differ | application code — the handler ran and decided nothing |
| `:frame_changed` | `render/1` produced a different frame | the render function — it ignores the assigns that changed |
| `:committed` | the frame was handed to the sender | the renderer or the bridge |
| `:navigation_requested` | the handler asked to navigate | `:unknown` — this screen cannot see whether the router honoured it |

`:frame_changed` rather than `:tree_changed` because the fingerprint covers
`{tree, Mob.Theme.current()}` — a handler that changes only the theme produces
an identical tree and a different frame, and calling that "the tree changed"
would be false.

This is why `owner/1` is computable rather than guessed. A defect report that
says "the tap did nothing" is a bug report nobody can route; one that says
"the handler ran and changed `:count`, and the tree did not change" points at a
`render/1` that never reads `:count`.

## What this does not know

A `render/1` that raises produces **no receipt**. The exception escapes from
the paint, which happens after the callback returned, outside the `try` that
wraps it — so the textbook `:render_function` defect is the one case with no
record. Covering it means wrapping the paint, which would change what a
render crash does to the screen, and that is a bigger decision than this
slice.

`native_commit` is `:unknown` on a receipt assembled from the BEAM alone.
Handing a frame to `Mob.Sender` is not proof the platform drew it, and the
native acknowledgement is not wired yet. A receipt says what the BEAM did; it
does not claim the pixels changed. Anything stronger would be the same
overclaim the process-wide counter makes, in better clothes.

# `error_summary`

```elixir
@type error_summary() :: %{
  kind: :error | :exit | :throw,
  exception: module() | nil,
  message: String.t() | nil,
  at: mfa() | nil,
  redaction: :applied
}
```

A crash, reduced to what is safe to keep.

Never the exception struct. Standard exceptions embed the term that failed —
`KeyError.term`, `MatchError.term`, `BadMapError.term` — so a `Map.fetch!/2`
against assigns puts the whole assigns map, secrets included, into the
receipt. That is exactly MOB-147's leak, and a receipt is written to ETS and
handed to telemetry, so it reaches a sink.

# `owner`

```elixir
@type owner() ::
  :event_routing | :app_code | :render_function | :renderer | :unknown | :none
```

# `stage`

```elixir
@type stage() ::
  :dispatched
  | :unobservable
  | :unhandled
  | :handled
  | :assigns_changed
  | :frame_changed
  | :committed
  | :navigation_requested
```

# `t`

```elixir
@type t() :: %Mob.Agent.Receipt{
  action_id: String.t(),
  after_frame_fingerprint: non_neg_integer() | nil,
  before_frame_fingerprint: non_neg_integer() | nil,
  elapsed_us: non_neg_integer() | nil,
  error: error_summary() | nil,
  event: term(),
  handler: mfa() | nil,
  monotonic_us: integer() | nil,
  native_commit: :unknown | :acknowledged | :rejected,
  screen: module() | nil,
  stages: [stage()]
}
```

# `describe`

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

A one-line summary for a human reading a triage log.

# `effect`

```elixir
@spec effect(t()) ::
  :verified
  | :navigation_requested
  | :no_visible_change
  | :not_committed
  | :inert
  | :unhandled
  | :unobservable
  | :error
```

The verdict an agent asked "did my action do anything" wants.

* `:verified` — a changed frame was committed.
* `:navigation_requested` — the handler asked to navigate. Reported separately
  because this screen deliberately does not paint for it: the owner applies
  the action and the destination paints, so deriving a verdict from the
  absence of a paint would call a screen push "inert". **It is a request, not
  an outcome** — the router may refuse it (a pop at the root, a push that
  fails to resolve) and simply repaint this screen. The receipt says what was
  asked; confirming what happened needs the router, which is not wired yet.
* `:no_visible_change` — the handler ran and the frame came out identical.
* `:not_committed` — a new frame was built and never handed to the sender.
* `:inert` — the handler ran and changed nothing.
* `:unhandled` — no clause matched the event.
* `:unobservable` — the screen is in `:no_render` mode, so no frame stage can
  be reached and no conclusion about the view is available.
* `:error` — the handler raised.

`:no_visible_change` is deliberately not an error. A handler that toggles a
value the current screen does not render has done exactly what it was asked;
whether that is a bug is the caller's judgement, and a framework that decides
it for them produces false failures.

# `new_action_id`

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

A fresh correlation id.

Unique within the node and cheap: this is on the path of every dispatched
event, so it must not be a bottleneck or a source of entropy exhaustion.

# `owner`

```elixir
@spec owner(t()) :: owner()
```

The layer answerable for this action producing no visible change.

`:none` when the action committed a changed frame — nothing to answer for.

A raise is attributed to `:app_code` — a crash in a callback is the
application's, and the stage list would otherwise blame whichever layer
happened to come next — unless it is the "no clause matched" raise, which is
routing.

# `reached?`

```elixir
@spec reached?(t(), stage()) :: boolean()
```

Did this action reach `stage`?

# `summarize_error`

```elixir
@spec summarize_error(:error | :exit | :throw, term(), Exception.stacktrace()) ::
  error_summary()
```

Reduce a raised exception to a summary that cannot carry application state.

Keeps the kind, the exception module, and the top stack frame — enough to
route a defect ("a `KeyError` in `MyScreen.handle_event/3`") without carrying
a single value out of the socket.

The **message is dropped** unless the exception is one whose message the
framework builds itself. This is deliberate and costs real diagnostic detail:
a `RuntimeError`'s message is usually the most useful line in the report. But
app code writes `raise "failed for #{inspect(user)}"` as a matter of routine,
and a receipt is written to ETS and handed to telemetry — a sink. Carrying it
would re-create MOB-147's `SecureField` leak in the mitigation named after it.

# `unmatched_event?`

```elixir
@spec unmatched_event?(term(), module()) :: boolean()
```

True when `error` is "no clause matched `handle_event/3` on this screen".

Distinct from a crash *inside* a handler, and the distinction is the whole
reason `:event_routing` and `:app_code` are separate owners. An event that
reached the screen and matched nothing is a routing problem — a tag that no
longer exists, a renamed event — and sending someone to read the handler body
wastes their time. A screen with its own `handle_event/3` clauses overrides
the catch-all `use Mob.Screen` injects, so an unmatched event arrives as a
`FunctionClauseError`; a screen with no clauses of its own raises
`Mob.Screen.UnhandledEventError` from that catch-all. Both are routing.

---

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