# `Mob.Invariant`
[🔗](https://github.com/genericjam/mob/blob/master/lib/mob/invariant.ex#L1)

Checks the framework can make about itself, and the rule that stops them
becoming noise.

An application cannot assert that a component's owning screen is still alive,
or that no dead screen is sitting in the navigation stack — it does not have
the handles. The framework does, each check is a few microseconds, and each
one guards a class of bug that recent releases kept re-fixing.

## A violation must survive to the next sample

This is the whole design, not a refinement. Every check here reads *live*
state from processes that are concurrently changing: a screen mid-teardown has
a dead pid and components that have not yet been reaped, and a check sampling
that instant sees a violation that resolves itself shortly afterwards.
Reporting it produces a defect nobody can reproduce, which is worse than
reporting nothing — it teaches the reader to ignore the channel.

So the first sighting of a violation is held as a **candidate**, and it is
recorded only when two things are true: the *same* violation is still there at
the next sampling of that point, **and** the candidate is at least 50ms old.

Both halves are needed, and the second was not obvious. Sampling points are
event-driven — the router stops screens in a tight loop, so during a
multi-screen reset "the next sample" can arrive in under a millisecond, and a
component still being reaped is seen twice. Surviving one teardown is
therefore *not* proof of anything; surviving 50ms is, because a real leak
persists indefinitely and does not notice the wait. With deferral alone,
healthy teardowns still produced about one confirmed violation in sixty.

The floor is `:mob, :invariant_min_candidate_age_us` for a device whose
teardown outlasts the default.

The first version of this re-ran the check immediately instead, back to back
in the same process. That was measured and it filtered nothing: the gap
between the two calls is about a microsecond and the transients it was meant
to catch last tens to hundreds, so it suppressed ~0% of them while reporting
a confirmed `:critical` on healthy teardowns. Two evaluations a microsecond
apart cannot disagree, which made the rule an assertion about nothing.

Sameness is by fingerprint over the violation's details, so a *different*
transient at the next sample does not confirm the first one.

That makes the checks themselves a contract: a check must be *deterministic
over stable state*, and its details must identify the violation rather than
describe the moment. One that samples something genuinely time-varying — a
timestamp, a queue length — cannot be expressed here, and should not be.

## It ships in release builds

Per `decisions/2026-09-04-defect-reports-are-a-shipped-feature.md`, the
interesting failures happen where no agent is watching. That makes the cost
real rather than theoretical, so it is budgeted rather than discovered on
someone's three-year-old Android: see `cost_us/2`, and the numbers in
`decisions/2026-09-10-an-invariant-must-survive-to-the-next-sample.md`.

## Registering

    Mob.Invariant.register(:my_check,
      at: :on_screen_stop,
      severity: :critical,
      check: fn context -> ... end
    )

A check returns `:ok`, `{:violation, details}`, or `{:violations, [details]}`
where each `details` is a map carrying **no application state** — the same rule
receipts follow. Pids, module names and counts are fine; assigns are not.

**Report independent problems separately.** A check that finds three leaked
components should return three violations, not one carrying a list. Each
matures on its own; rolled into one, the details change whenever any of them
does, the fingerprint changes with it, and nothing ever confirms.

# `context`

```elixir
@type context() :: map()
```

# `point`

```elixir
@type point() :: :after_committed_frame | :on_screen_stop | :periodic
```

# `result`

```elixir
@type result() :: :ok | {:violation, map()} | {:violations, [map()]}
```

# `severity`

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

# `cost_us`

```elixir
@spec cost_us(point(), context()) :: non_neg_integer()
```

Microseconds to run every check registered for `point` once, measured now.

For budgeting on the device that matters rather than on a laptop. Runs the
checks for real, so it observes whatever the app is currently doing.

This is one pass of each check. A sampling point also does candidate
bookkeeping and, on confirmation, a record — so a real sample costs somewhat
more than this reports. The table in the decision record is measured through
`run/2` and is the number to budget against.

# `register`

```elixir
@spec register(
  atom(),
  keyword()
) :: :ok
```

Register a check.

Re-registering the same name replaces the previous definition, so a hot code
push does not accumulate duplicates.

# `registered`

```elixir
@spec registered(point()) :: [map()]
```

Every check registered for `point`.

# `run`

```elixir
@spec run(point(), context()) :: [Mob.Invariant.Violation.t()]
```

Run every check registered for `point` against `context`.

Returns the violations confirmed by this run: ones also seen at the previous
sampling of `point` **and** whose candidacy is at least
`:mob, :invariant_min_candidate_age_us` old (50ms by default). A violation
seen for the first time, or too recently, is held as a candidate and returns
nothing. Never
raises: a check that blows up is itself reported as a violation of
`:invariant_check_failed` rather than being allowed to take down the process
that was kind enough to sample.

# `unregister`

```elixir
@spec unregister(atom()) :: :ok
```

Forget a check.

# `violation_count`

```elixir
@spec violation_count() :: non_neg_integer()
```

How many violations are held.

# `violations`

```elixir
@spec violations(pos_integer()) :: [Mob.Invariant.Violation.t()]
```

The violations held, newest first.

---

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