Skip to content

Add mix mob.attest — prove the device is running the code you just pushed (MOB-152) - #65

Merged
GenericJam merged 2 commits into
masterfrom
feat/mob-152-attestation
Sep 5, 2026
Merged

Add mix mob.attest — prove the device is running the code you just pushed (MOB-152)#65
GenericJam merged 2 commits into
masterfrom
feat/mob-152-attestation

Conversation

@GenericJam

Copy link
Copy Markdown
Owner

mix mob.deploy reports what it did, not what is now true.

Two instances from one session:

  • A bundle-id divergence sent the BEAM push into one app's container while a different app was running. It didn't fail with "not installed" — it succeeded and printed a tick, because both containers existed on the device.
  • A plain dist deploy reported success while twelve mob modules on the device kept their old digests.

In both cases every individual step was honest about itself and the run as a whole was wrong. Every guide in these repos says "verify effects, not exit codes" — an instruction that exists precisely because the tools can't be trusted, and that only works while someone remembers to follow it. This makes it checkable.

How

module_info(:md5) on the device is the same digest :beam_lib.md5/1 gives for the local .beam, so a module that never arrived, arrived in the wrong place, or arrived and was never loaded all show up — no new fingerprinting machinery needed.

Deliberately not hashing artifacts or directories: two builds of the same source differ in timestamps and paths, so an artifact hash reports a mismatch on every rebuild. A check that cries wolf gets switched off, and a switched-off check is worth less than none because its absence is invisible.

Four verdicts, not two:

:stalefatal — the device is running code we did not build
:unreadablefatal — the check could not run, and that is not a pass. Same rule as a deploy exiting 0 having shipped nothing
:missingreported, not fatal — interactive BEAM loads a module on first call, so most of a bundle is legitimately unloaded
:matchthe only pass

It connects without restarting: Connector.connect_all/1 restarts the app, which reloads every module and would destroy the evidence being read.

It found a live failure on its first real use

$ mix mob.deploy --ios --device 8A4250E9-...
iPhone 17 Pro → pushing... ✓ (dist, no restart)
Deployed to 1 device(s) # exit 0
$ mix mob.attest --app mob
… 55 match, 12 stale, 0 not loaded, 0 unreadable # exit 1

Verified by hand from an independent node: local DA73F8D7… vs device DD2B00C8…, loaded from ~/.mob/runtime/ios-sim/. So not a defect in the attestation — a real deploy that reported success while the device ran different code. Filed as MOB-161 (P1); root cause not yet diagnosed and out of scope here.

Verification

Mutation-tested with mix mob.mutate from the previous ticket: 22 killed, 0 survived. It found one gap on the way — the sort in name_list/1 was untested, so report order could follow map iteration and make two runs of the same check look different, which is exactly when someone stops reading it.

2327 tests, credo and format clean, decision record included.

…shed
`mix mob.deploy` reports what it did, not what is now true, and the two come
apart more often than the exit code suggests.
Two instances from one session. A bundle-id divergence sent the BEAM push into
one app's container while a different app was running: it did not fail with
"not installed", it succeeded and printed a tick, because both containers
existed on the device. And a plain dist deploy reported success while twelve
`mob` modules on the device kept their old digests. In both cases every
individual step was honest about itself and the run as a whole was wrong.
Every guide in these repos says "verify effects, not exit codes". That
instruction exists because the tools cannot be trusted, and it only works while
someone remembers to follow it. This makes it checkable.
`module_info(:md5)` on the device is the same digest `:beam_lib.md5/1` gives
for the local `.beam`, so a module that never arrived, arrived in the wrong
place, or arrived and was never loaded all show up, and no new fingerprinting
machinery is needed. Deliberately not hashing artifacts or directories: two
builds of the same source differ in timestamps and paths, and a check that
cries wolf gets switched off — which is worse than none, because its absence is
invisible.
Four verdicts. `:stale` is fatal, the device is running code we did not build.
`:unreadable` is fatal too: the check could not run, and a check that could not
run must not report success — the same rule as a deploy exiting 0 having
shipped nothing. `:missing` is reported and is NOT fatal, because interactive
BEAM loads a module when something first calls it and most of a bundle is
legitimately unloaded at any moment. A run that reaches no device raises rather
than passing.
It connects without restarting. `Connector.connect_all/1` restarts the app,
which reloads every module and would destroy the evidence being read; the
tunnels from a previous `mix mob.connect` are device-level and outlive it, so
`Node.connect/1` is enough.
It found a live failure on its first real use: a deploy printed
"✓ (dist, no restart)" and exited 0 while twelve modules stayed stale, verified
by hand from an independent node (local DA73F8D7… vs device DD2B00C8…, loaded
from ~/.mob/runtime/ios-sim/). Filed as MOB-161; the root cause is not yet
diagnosed and is not in scope here.
Mutation-tested with `mix mob.mutate` from the previous ticket: 22 killed, 0
survived. It found one gap on the way — the sort in `name_list/1` was untested,
so report order could follow map iteration and make two runs of the same check
look different.
Refs MOB-152, MOB-161
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…thing
An adversarial review — which this branch shipped without, and which I only
ran when asked — found that the digest comparison was sound and everything
wrapped around it was not.
A node that went away mid-run scored every module `:missing`, and `:missing`
was non-fatal. Four hundred dead-node modules produced `0 match, 0 stale, 400
not loaded` followed by "the device is running this build" and exit 0: zero
evidence gathered, tick printed, which is precisely the failure this task was
written to abolish, reproduced inside it. A test of mine asserted that
behaviour, which is how it survived. Every badrpc other than `:undef` is now
`:unreadable`, which is fatal.
`verdict([])` returned `:ok`, so a typo'd `--app` globbed nothing, compared
nothing and passed. An empty scope now refuses, and `--app` naming no beams
refuses by name.
Unreachable devices were printed and then dropped. With two phones attached
and one wedged, attest checked the healthy one and emitted `"outcome": "ok"`.
Unreachable is now carried into the report, into the JSON, and into the exit
code.
The default scope was wrong in a way that undercut this task's own evidence.
It checked only the project's application, while `mix mob.deploy` pushes the
whole runtime dependency tree — and MOB-161's twelve stale modules were in
`mob`, a dependency. The default would have reported `:ok`; I found it only
because I happened to pass `--app mob`, then wrote "found it on its first real
use" into the changelog and the decision record as though the default would.
It now defaults to exactly `HotPush.runtime_beam_dirs/0`, so what is attested
cannot drift from what was shipped, and `--app` narrows. On device that moves
the check from 67 modules to 2507 and catches the staleness with no flag.
The `:missing` rationale was backwards, and measuring settled it. The doc said
interactive BEAM loads lazily, so unloaded modules are normal and `:missing`
must be tolerated. On a real device the code server IS interactive, so probing
`module_info(:md5)` triggers the load and returns a digest — `:undef` therefore
means the module is on no code path at all, which for something just pushed is
a real failure. It is fatal now. Two consequences stated in the docs: the check
is stronger than described, comparing the file the device would load rather
than the resident set, and it has a side effect, since probing an unloaded
module loads it.
Node names were re-derived instead of using the one discovery already resolved.
For WiFi-adb Android those disagree — adb id versus `ro.serialno` — a
divergence this repo documents as a fixed bug in `discovery/android.ex`, and
reintroducing it landed the device silently in the unreachable list. Now reads
`device.node`.
Also: `Node.connect/1` returns `:ignored` when the local node is not alive and
`:ignored` is truthy, so every candidate could land in "reachable" and then
fail every RPC; `--cookie`, since a project on a non-default cookie could never
connect and was told to run `mix mob.connect` first; the run now reports what
answered (code root, OTP release, code mode), because two containers from one
project register the same node name and only one wins the EPMD slot; and the
cap test did not test the cap, as the review demonstrated by mutating take(5)
to take(6) and watching it pass.
The task module had no behavioural tests at all — the mutation run I quoted
covered only the pure scorer. `finding/2` now takes its digest fetcher, and it
plus `outcome/2` and `module_from_path/1` are tested.
Device-verified after the fixes: default scope catches 13 stale modules across
2507; `--node nope@127.0.0.1` exits 1; `--app mobb` refuses by name; `--app
mob` still narrows to 55/12.
Refs MOB-152
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@GenericJam

Copy link
Copy Markdown
OwnerAuthor

Adversarial review is in — and this branch shipped without one, which I only ran when asked. Its verdict on the core: the digest comparison is sound (it verified that :beam_lib.strip/1 is md5-preserving, and that push_beams sends the exact bytes we digest). Everything wrapped around it was not.

Three ways it reported green having checked nothing

A node that went away mid-run scored every module :missing, which was non-fatal. 400 dead-node modules produced 0 match, 0 stale, 400 not loaded followed by "the device is running this build" and exit 0. Zero evidence gathered, tick printed — precisely the failure this task was written to abolish, reproduced inside it. A test of mine asserted that behaviour, which is how it survived. Every badrpc other than :undef is now :unreadable, which is fatal.

verdict([]) returned :ok, so a typo'd --app globbed nothing, compared nothing, and passed.

Unreachable devices were printed and then dropped. Two phones attached, one wedged → attest checks the healthy one and emits "outcome": "ok".

The default scope undercut this task's own evidence

It checked only the project's own application, while mix mob.deploy pushes the whole runtime dependency tree — and MOB-161's twelve stale modules were in mob, a dependency. The default would have reported :ok. I found it only because I happened to pass --app mob, then wrote "found it on its first real use" into the changelog and decision record as though the default would. Both now corrected.

Default is HotPush.runtime_beam_dirs/0, so what is attested cannot drift from what was shipped. On device that moves the check from 67 modules to 2507 and catches the staleness with no flag.

The :missing rationale was backwards, and measuring settled it

The doc said interactive BEAM loads lazily, so unloaded modules are normal and :missing must be tolerated. Measured on a real device: the code server is interactive, so probing module_info(:md5)triggers the load and returns a digest. :undef therefore means the module is on no code path at all — for something just pushed, a real failure. Now fatal.

Two consequences now stated in the docs: the check is stronger than described (it compares the file the device would load, not the resident set), and it has a side effect — probing an unloaded module loads it.

Also

Node names were re-derived rather than taken from discovery; for WiFi-adb Android those disagree (adb id vs ro.serialno), a divergence this repo documents as a fixed bug, and reintroducing it landed devices silently in the unreachable list. Node.connect/1 returns :ignored when the local node isn't alive, and :ignored is truthy. Added --cookie. The run now reports what answered (code root, OTP release, code mode), since two containers from one project register the same node name and only one wins the EPMD slot. And the cap test did not test the cap — the reviewer mutated take(5) to take(6) and watched it pass.

The task module had no behavioural tests; the mutation run I quoted covered only the pure scorer. finding/2 now takes its digest fetcher and is tested alongside outcome/2 and module_from_path/1.

Verification

Device: default scope catches 13 stale across 2507; --node nope@127.0.0.1 exits 1; --app mobb refuses by name; --app mob still narrows to 55/12. 2337 tests, credo and format clean, mix mob.mutate on the kernel: 23 killed, 0 survived.

@GenericJam
GenericJam merged commit 5cf0a31 into masterSep 5, 2026
3 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@GenericJam