Add mix mob.attest — prove the device is running the code you just pushed (MOB-152) - #65
Conversation
…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
commented
Sep 5, 2026
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 Three ways it reported green having checked nothingA node that went away mid-run scored every module
Unreachable devices were printed and then dropped. Two phones attached, one wedged → attest checks the healthy one and emits The default scope undercut this task's own evidenceIt checked only the project's own application, while Default is The |
Uh oh!
There was an error while loading. Please reload this page.
mix mob.deployreports what it did, not what is now true.Two instances from one session:
mobmodules 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/1gives 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:
:stale:unreadable:missing:matchIt connects without restarting:
Connector.connect_all/1restarts the app, which reloads every module and would destroy the evidence being read.It found a live failure on its first real use
Verified by hand from an independent node: local
DA73F8D7…vs deviceDD2B00C8…, 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.mutatefrom the previous ticket: 22 killed, 0 survived. It found one gap on the way — the sort inname_list/1was 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.