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.
Summary
Functions
Microseconds to run every check registered for point once, measured now.
Register a check.
Every check registered for point.
Run every check registered for point against context.
Forget a check.
How many violations are held.
The violations held, newest first.
Types
Functions
@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 a check.
Re-registering the same name replaces the previous definition, so a hot code push does not accumulate duplicates.
Every check registered for point.
@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.
@spec unregister(atom()) :: :ok
Forget a check.
@spec violation_count() :: non_neg_integer()
How many violations are held.
@spec violations(pos_integer()) :: [Mob.Invariant.Violation.t()]
The violations held, newest first.