Written 2026-08-02, when the project was shut down. Not a post-mortem of a failure to ship — it shipped, five releases, 509 passing tests, a working radio client. It's a post-mortem of arriving at something nobody wanted to use, including the person who asked for it.
The verdict that ended it: "our app is complex, it feels like you need to know what you are doing" and "there is functionality that just doesn't work." Both were true, and both were predictable months earlier.
We optimised a number that wasn't the goal — feature coverage against a competitor's class list — and never once measured the thing that was.
Everything below is a variation on that.
PARITY.md was built by pulling a competitor's APK, extracting Dart class names from
libapp.so, and turning each *Screen / *Sheet / *Dialog into a row. That yields a
list of surfaces. It says nothing about flow, hierarchy, what's one tap versus three,
or what a user is trying to do.
We then built one screen per row. The result was a complete inventory and an incoherent app.
Next time: if you must copy, copy the information architecture — what's a tab, what's a screen, what's a sheet, how you get from a node to administering it. That's the part users feel. The widget inventory is downstream of it.
We copied inventories three times and structure zero times.
- v1 scope came from MeshCore Open —
SCOPE.mdsays so: "pruning the MeshCore Open feature inventory (~26 screens) down to a deliberate v1." We took the list of screens. We did not take how those screens relate. - The UI foundation came from
../reticulum-mobile-app— a different app, for a different protocol, with different screens and a different navigation model. So MCO's features were implemented into an information architecture designed for something else. - Later scope came from the closed mainstream app, inventoried by scraping Dart class
names out of
libapp.so. Another list of names, another zero structure.
MCO's full source — including repeater_hub_screen fronting separate status / settings /
CLI / regions / neighbours screens — sat in a sibling directory the entire time. We had
already read it well enough to enumerate its screens. We never once looked at how it
connected them, and shipped a six-tab mega-screen where they have a hub.
The same mistake, three times, from three references: a list of screens is not an architecture, and every feature you copy needs somewhere to live that you actually chose.
Next time: when you take a feature list from a reference, take its navigation graph in the same sitting. They are one artefact. If you're inheriting a foundation from a third app, that's the moment to redraw the IA deliberately — not to let the borrowed shapes decide.
On 2026-08-01 the standard silently moved. SCOPE.md gained a policy note making the
closed mainstream app the "floor", and PARITY.md — 64 rows scraped from class names —
superseded a curated ~26-screen scope. The handover is written into the file: "Where this
document and PARITY.md disagree, PARITY.md wins."
Scope roughly tripled in one edit, from a list someone had pruned on purpose to a list
generated by strings. It was documented and blessed rather than challenged, which is
what made it invisible: it didn't look like scope creep, it looked like planning.
Next time: the reference you chose in the charter is the reference. Changing it is a new project and deserves the same scrutiny as starting one — including re-asking whether you still want to.
This app's real differentiator was posture: no servers, no accounts, no analytics, and a refusal to state more than it knew. That's worth something to roughly one person.
It was also, implicitly, competing on features with an established app that has design attention and a user base. Feature parity was achievable. UX parity is a different discipline and a much longer road, and we never decided we were on it — we just drifted onto it, one PARITY row at a time.
Next time: write down "this is a tool for me" or "this competes." They justify opposite investments. Drifting between them gets you the costs of both.
The status line read 44 ✅ · 10 ◐ · 8 ❌. It went up every session. It felt like
progress and was reported as progress.
It measured screens that existed. Not screens that worked, were reachable, were understandable, or had ever been run. A tally with a gradient will be climbed, so be extremely careful what gradient you install.
Next time: if you keep a scoreboard, make the top-line number something like "screens driven on real hardware this month." Feature counts belong in a footnote.
Repeated theme, three separate incidents:
CMD_SEND_TRACE_PATHshipped with a hardcoded flags byte because the spec line named a field without saying what went in it. The reference client's parser was read; its sender wasn't.freqHzin the reference client is computed(freqMHz * 1000)— it's kHz. We copied the name into our spec, our model, our frame builder and our UI label, and sent values 1000× too large. The radio rejected every regional preset.path_lenis not a length. Low six bits are a hop count, top two bits a width.
Names in reverse-engineered protocols are frequently wrong, because the person who wrote them was also guessing. The authoritative answer is at the call site.
Next time: for every wire field, find the line that produces the value, not the declaration. Paste it into the spec as evidence.
MESHCORE_PROTOCOL.md recorded freq_hz 300 000–2 500 000 — correct — and then
annotated it "firmware uses Hz here." 300 000 Hz is not a LoRa band. The range was
decisive on its own and had been sitting in the file, right, the whole time.
The guess written beside the evidence propagated into the code and survived months.
Next time: when you write a
?or a "probably" into a spec, make it a blocking TODO, not a footnote. Uncertainty recorded next to correct data reads as resolved.
Four separate defects, one shape: a value that depends on a mesh property
(pathHashByteWidth — 2 on this network, 1 by default) computed once, at the wrong
width, then carried around as truth.
Trace flags. Path-history hop counts. Apply-path token parsing. The repeater picker's
take(2).
The fix that finally stuck was structural: stop carrying the derived value. A picked hop now stores the node's full public key and computes its hash at the current width on demand. A width that arrives late corrects the display instead of pinning a wrong route.
Next time: if X is derived from context, store the source and derive at use. Caching a derived value is a bet that the context is stable — make that bet explicitly or not at all.
This is the sharpest lesson of the project.
The frequency bug lived through 509 passing tests. It survived because we wrote
910525000 into the frame builder, the response parser, the SELF_INFO test fixture and
the fake radio. Every component agreed with every other component. The only party that
disagreed was the radio, and it wasn't in the test suite.
The trace bug was the same class one level down: nine tests, all on the receive side, and a sender that emitted a packet no node would answer.
Next time: at least one test per protocol feature must pin a value that came from outside your codebase — a capture, a reference implementation's own test vector, a device. If you can't get one, write in the test that you couldn't, and treat the feature as unverified.
assertEquals(910_525_000L, usa.frequencyHz) passed forever. What would have caught it on
day one:
assertTrue(p.frequencyKhz in 300_000..2_500_000) // the band the firmware acceptsExamples confirm what you already believe. Constraints catch what you didn't think of.
Most heard-via tests assert a route is not claimed — ambiguous packets, wrong sender, stale timing. Every one would pass if the feature did nothing at all. The test carrying that suite is the single one asserting a real path reaches the event.
Whenever correctness means declining to answer, pin the case where it must answer.
"Build succeeded, 509 tests passed" was reported — by me, repeatedly — as though it meant the feature worked. It meant the code compiled and agreed with itself.
Most of this app met a real radio for the first time in a single session near the end. Roughly a dozen defects surfaced within hours: phantom channels, a dead trace, an Apply button that did nothing, tabs truncated to "Statu", hop counts that were byte counts, frequency in the wrong unit.
They were not subtle. They were just never looked for.
The archetype: a "Guest (read-only)" checkbox beside the repeater password field.
The node reports what it granted — PUSH_CODE_LOGIN_SUCCESS[1], 1 = admin. We parsed that
byte, threw it away, and set the session's rights from the checkbox. So the control
could not work: tick it with an admin password and the UI locks controls the node would
allow; leave it clear with a guest password and it offers controls the node will refuse.
The competing apps have no checkbox. You type a password; the node decides; the UI shows
ADMIN or GUEST.
Same smell elsewhere: manual routing demanded hex tokens whose width is a property of the mesh the user has no way to know.
Next time: for every input, ask "could the system determine this itself?" If yes, it's not a setting, it's a bug with a label.
We ended with a repeater admin screen of six scrollable tabs and a settings screen of ten expandable sections. Neither was designed; both accumulated, because adding to an existing screen is free and creating one has a cost.
MCO solves the same problem with hub-and-spoke: a hub screen fronting separate status, settings, CLI, regions, neighbours and telemetry screens.
Next time: make "which screen does this belong on?" a required question per feature, with "a new one" as a normal answer. If a screen grows a third tab, that's the signal to split it, not to add a fourth.
A genuine strength of this app was refusing to overstate: "which repeaters carried it isn't known", "channels are obfuscated, not secure", "flooded — no route was recorded." Those distinctions are real and I'd keep every one.
But the caveat went on every row of every screen, three sentences at a time, until the whole app read like a disclaimer. The competing apps say nothing and look clean.
Next time: state the posture loudly in one place. On individual surfaces, use the shortest true label and let a tap reveal the nuance.
The plan was always "features now, polish later." That's not how it works. By the time you notice, the navigation is wrong, the mega-screens exist, and fixing it is a rewrite of the UI layer rather than a tidy-up.
Next time: build the shell first — navigation, five screens done properly, driven on hardware — and add features into a frame that already works. A feature landing in a good frame stays good. A feature landing in a bad one makes it worse.
Everything above about hardware defects reduces to this. Weekly would have caught nearly all of it while it was one bug rather than a texture.
iOS CI was red for multiple commits and I never looked. Worse, the failure wasn't
iOS-specific: a JVM-only API (toSortedMap()) had reached commonMain, so the shared
module had been non-portable for weeks.
Next time: either watch the signal or delete it. A permanently-red check trains you to ignore the whole dashboard.
README said "v1 shipped." PARITY said 44 ✅. The in-app changelog credited 0.3.0 with
four features that landed after that tag, so the released build didn't contain them.
Overclaiming docs don't just mislead others — they mislead you, three months later, about what's actually done.
- Decide: personal tool, or competitor? Write it down.
- Inventory what open-source references exist before reverse-engineering anything.
- Build the shell: navigation, IA, five screens, running on real hardware.
- Add features into that frame, one at a time, each driven on hardware before it counts.
- For every wire field, cite the reference implementation's sender in the spec.
- For every protocol feature, pin at least one value from outside your own codebase.
- Keep a scoreboard, but count screens driven, not screens written.
- Re-read every checkbox once a month and ask what the system already knows.
Not everything here was waste:
- The protocol layer (
shared/) is solid, well-documented and genuinely reusable.MESHCORE_PROTOCOL.mdis better than anything else publicly available on the companion protocol, unit bug and all — now corrected, with its evidence attached. - The security posture held. Advert signatures verified before import; secrets in the keystore; the DB encrypted; channels honestly labelled obfuscated; TCP behind a stern warning. None of that was ever traded away for convenience.
- The refusal to guess — ambiguous hops shown as gaps rather than pinned to a plausible candidate; routes refused rather than truncated; "isn't known" instead of "direct". That instinct is right, and the mistake was only in how loudly it was repeated.
- The hardware-debugging discipline, once it finally started: pin the captured value, test hostile input, sweep the space exhaustively. It found real defects every time it was applied. It just started about four months too late.