Mob.Agent.Receipt (mob v0.8.2)

Copy Markdown View Source

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:

StageReached whenIf it stops here
:dispatchedthe screen received the event
:unhandledno clause matched the eventevent routing — a stale tag, a renamed event
:handledhandle_event/3 returned— it raised; see :unhandled below
:assigns_changedthe socket's assigns differapplication code — the handler ran and decided nothing
:frame_changedrender/1 produced a different framethe render function — it ignores the assigns that changed
:committedthe frame was handed to the senderthe renderer or the bridge
:navigation_requestedthe 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.

Summary

Types

A crash, reduced to what is safe to keep.

t()

Functions

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

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

A fresh correlation id.

The layer answerable for this action producing no visible change.

Did this action reach stage?

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

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

Types

error_summary()

@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()

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

stage()

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

t()

@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()]
}

Functions

describe(receipt)

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

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

effect(receipt)

@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()

@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(receipt)

@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?(receipt, stage)

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

Did this action reach stage?

summarize_error(kind, reason, stacktrace)

@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?(arg1, screen)

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