Building the messenger that deserves to exist

Published on 8 Jul 2026

Building the messenger that deserves to exist
Group messaging without a central server is a consensus problem. We stopped patching the symptoms and rebuilt the protocol from the state machine outward. Here's the full story.

The next chapter

The last shipped build was v2026.5.22+25, back at the end of May. The commit graph on the Flutter app went quiet on June 9. There were zero conference talks in June. The team went dark, and the app on your phone kept working most of the time and failing in the ways it had been failing for a while.

Here is the story of what happened in that quiet. It has two threads that started separately and ended up braided together. One is a protocol problem we underestimated. The other is a client platform we outgrew. Both problems needed a longer answer than a patch release, and the answer to both is now in flight. The rest of this post walks through what we found, what we built, and what still stands between us and the next release.

The bug behind the bug

For most of White Noise's first year, a specific failure kept showing up: someone would create a group, invite a few people, those people would add a few more, and at some point the group would stop working. Messages would arrive for some members and skip others. Two admins would see two different member lists. A new invitee would join and immediately be in a group that already disagreed with itself.

We treated these for a long time as individual bugs. Wrong retry logic. A race between a commit and a welcome. A missing tie-breaker. Each fix was correct in isolation, and yet the underlying problem stayed.

The underlying problem is that maintaining a shared group state across many independent clients, over an open relay network, is a consensus problem. We mean consensus in the specific sense that has kept distributed systems research busy for decades: multiple actors making decisions at the same time, over a network that reorders and drops messages, with no privileged coordinator to arbitrate. MLS gives you strong cryptographic primitives for a single well-behaved epoch chain. It stays silent about what to do when two admins commit at the same instant and both are technically valid.

We had built the app on the assumption that the MLS state machine plus a few local rules would be enough. What we found was that the rules had to be much more precise, the machine had to be described formally, and every failure mode had to be enumerated by name. Otherwise every client team writing an implementation would invent slightly different rules and the groups would split down the seams between them.

Once we saw the shape of the problem, the plan changed.

Dark Matter: the exploration

In May we opened a repo called darkmatter. The description says "An exploration." It was a candidate rewrite of the protocol from the group-state machine outward.

The work that came out of Dark Matter has three parts.

The spec.​ The Marmot spec got reorganized from a numbered list of MIPs into surfaces: foundation, protocol-core, app components, transports, and features. That reflects how implementers think about it in practice. The group lifecycle now has a small, named set of states: Stable, PendingPublish, Merging, Recovering, Unrecoverable. Concurrent commits get a deterministic tie-breaker that every conforming client must implement the same way. The convergence policy is nailed down to six pinned constants, deliberately fixed and undecided by any group, so that groups cannot fork on configuration.

The engine.​ Alongside the spec work, we wrote a fresh CGKA engine, the Continuous Group Key Agreement state machine that sits on top of MLS. It has a much cleaner boundary and a much clearer error taxonomy. This engine is where the "verifiable state machine" language you may have seen comes from: it now has Tamarin proofs for the convergence selector and the lifecycle boundaries, plus a conformance simulator that runs multi-client scenarios and checks the outputs against an oracle. When we say "we now understand where this breaks," we mean it in the sense that the proof either goes through or it fails to.

Capability negotiation.​ Another insight from our prior work on White Noise and Marmot was that clients will always support different feature sets. Amethyst may have a feature White Noise hasn't shipped yet. An older Scramble build may lack something the two newer clients agree on. The old approach was to expect all client devs to stay caught up, which is a wish, not a plan. The new approach: when a group opens, the members handshake on the intersection of capabilities they all support at that moment, and the group operates at that level. Alice with more features and Bob with fewer can still open a group together; the group uses what they both have and stops there. This shipped over five PRs in late May. The beautiful thing about how this is built is that it allows groups to upgrade over time as their respective clients support newer features.

The multi-device groundwork is in this batch too. It is deliberately off for interop for now, since history sync is out of scope for the first release. The spec now has the shape of a device set per account, and the engine knows how to reason about it. Multi-device will ship when it works.

Transport Agnostic.​ The last major improvement that the Dark Matter work tackled was make Marmot transport agnostic. Nostr is still our first (and only) transport adapter for the moment, but by separating how messages are carried across the wire we set the groundwork for a more flexible and unstoppable protocol.

The Dark Matter migration is complete. All the new code has landed back in MDK and the darkmatter repo will be archived soon. What started in the exploratory repo has been folded back into the Marmot spec, into MDK, and into every downstream clients (more on this later). marmot-ts, the TypeScript implementation, has also begun implementing the Marmot v2 spec, which means non-Rust clients get a path to the same conformance guarantees.

Goggles: seeing groups fail

You cannot fix consensus bugs you cannot see. Every long-running group in production had been telling us something was wrong, and we lacked a good way to look at what the group had done, from every member's point of view, at once.

So we built Goggles, a visualization dashboard for Marmot group state. Every engine emits a forensic audit log: one JSONL line per event, everything it sent, everything it published to relays, everything it received and decrypted, every epoch it moved through, every fork verdict it computed. Goggles ingests these logs from every device in the group, joins them by message ID, and shows you what each engine saw and decided.

The load-bearing feature is the per-engine matrix. If Alice thinks the group is at epoch 47 and Bob thinks the group is at epoch 46 with a different membership, Goggles shows you both, side by side, with the exact epoch where they disagreed. It gives you a fork verdict: the specific epoch where a same-epoch commit was resolved to different winners by different machines. That is the exact point the group split.

Goggles changed how we debug. We stopped guessing at what happened after the fact and started reconstructing it. The audit-log format is now on its second schema version: v2 shipped on June 26 as an explicit contract between the engine and Goggles, called out that way in the commit message.

Obviously, audit logging of this level shares sensitive data with a central server. These audit logs are opt-in developer tools that we don't expect most users to turn on or pay attention to.

Virtual groups: making the failure happen on purpose

Seeing failures after the fact is one half of the job. Reproducing them on demand is the other. In June we spun up an automated testing harness that boots throwaway virtual machines, installs the app in each one, and drives them from the outside as if a real user were tapping through. A test run becomes a small virtual group with several members, all doing something specific: chatting normally, joining late, going offline and coming back, handing off admin rights, running two overlapping groups at once, or a 48-hour marathon soak that watches for slow drift.

The harness writes the same forensic audit log format Goggles reads, so a test failure and a production failure show up in the same tool. Every run reports whether it passed, failed, or produced a fork. When the harness finds a fork, we walk the logs into Goggles and find the epoch, then walk the epoch back into the engine and fix the rule.

The harness gives us an operational grip on the problem. It is a stretch to claim we have solved every case. What we can claim: when a new case appears, we have a tool that finds it and a tool that names it, and the fix goes back into the spec so it will stay fixed for every conforming client.

Speaking of clients...

The other pivot: Flutter to native

Somewhere in the middle of the Dark Matter work, an older question came due. The Flutter app had gotten us this far, and it was starting to hold us back. The same UI on Android and iOS meant the app never felt at home on either. Native features (the platform notification model, the platform keychain, the OS-level share sheet, the small everyday things a good chat app has to get right) kept requiring more workarounds than they were worth. And the layer between our Rust core and the Flutter widget tree had grown into its own maintenance burden.

We decided to reimplement the UI in native code, per platform. Big call, and we did not make it casually. It costs us calendar time. It costs us duplicated UI work. But there were two reasons that we felt this was the right way forward. The first was related to what we said, purely native apps behaves the way its host operating system expects it to behave, and a UI layer that is easier to reason about and way nicer to use as an end user. Second, agentic coding has massivly collapsed the cost of implementation and maintenance of multiple code bases, which was always one of the main selling points of frameworks like Flutter.

  • Android is being rewritten in Kotlin with Jetpack Compose. Repo: whitenoise-android. Roughly eighty PRs merged in the last week alone, moving through screen-lock gating, chat-screenshot privacy toggles, in-app font scaling, and the redesigned chat-creation and group-management flows.

  • iOS is being rewritten in Swift and SwiftUI. Repo: whitenoise-ios. Recent work has been a systematic thin-shell refactor pushing state down into Rust and decomposing the app-level state model, plus a Notification Service Extension that decrypts pushes on-device with Apple seeing only ciphertext.

  • macOS is a first-class native app in SwiftUI. Repo: whitenoise-mac. Recently through a full performance pass, first TestFlight build, and multi-recipient new-chat flow. There is also a small companion repo, wn-design-lab-mac-os, where the team prototypes with real AppKit primitives to keep the app feeling native at the level of controls, and beyond skin-deep paint.

  • Linux (with Windows to follow)​ is a single Rust binary with a Slint UI. Repo: whitenoise-linux. The v0.1.0 line is out, day-to-day usable, and, because it exposes a headless daemon, it doubles as the testbed the QEMU harness drives.

All of the native apps share the same Rust engine underneath. The engine is where the Marmot protocol lives. The native layer sits above it to render, take input, and integrate with the operating system.

Building four UIs is more work than building one. We think it is worth it because the app you use for private group messaging should feel like it belongs on the device you use for everything else. Signal made that call years ago, iMessage was born there, and every serious mobile messenger followed the same route. The Flutter era was a productive shortcut for shipping the first version of an idea. The native era is where the app grows up.

Where we are

The next release lands when it lands, and we are staying quiet on the date. Here is where things stand:

  • The spec side of Marmot v2 is drafted and organized, with formal proofs for the convergence pieces that used to be underspecified. The engine that implements it runs in the test harness against real scenarios.

  • The Rust core (MDK) has been cleaned up and modularized in step with the spec, and it is the code path every native client uses.

  • The native rewrites are all shipping code every day. iOS is running on TestFlight-shape builds internally, the Mac app has real users on the team, Linux has a public v0.1.0 out, and Android is deep into feature parity.

  • We can reproduce group forks on demand in the testing harness, and we can see them in Goggles when they show up in the wild. What was invisible to us six months ago is now legible.

What remains is testing and calibration. Turning the harness on for longer runs and stranger scenarios. Watching the audit logs in Goggles for divergence patterns we have yet to name. Fixing them at the spec layer, so every conforming client inherits the fix. Then re-running the harness. The next release will ship when a medium-sized group can survive the marathon scenario clean, and when the fix set has held long enough that we trust it.

The reason this is taking longer than any of us wanted is that we changed our mind about what "shipping" means. Shipping a chat app that mostly works, and hoping the group-consensus bugs stay rare, is a game we are done playing. Shipping one that works because we understood the problem and rebuilt the parts that could survive it: that we can stand behind.

What you can do in the meantime

If you want to follow along, the work is open. The interesting repos, roughly in order of "where the story is":

If you are running the current Flutter build in a group that has misbehaved, and you would be comfortable sharing an audit log with us, get in touch. The forensic log format is the same one Goggles reads, and every real-world fork we see makes the next release better.

Contributors

Thanks to everyone who has been heads-down on this over the last three months: erskingardner, agent-p1p, mubarakcoded, pepina-dev, dannym-arx, jgmontoya, and JeffG.​

We are trying to build the first fully decentralized, end-to-end encrypted group messenger that works at group sizes people use every day. It is a genuinely hard problem, harder than we thought when we started, and we are further into solving it than maybe anyone else that has worked on the problem.

The next release will land when it deserves to. Thank you for the patience.