Symptom

The avatar UI element and the KrillApp.Client swarm node were the same thing — both lived at installId(), and the avatar’s menu was computed by NodeChildren.load(KrillApp.Client). That conflation was load-bearing for the FTUE flow (the wide “what would you like to do?” menu shown in the avatar’s speech bubble) but it blocked Phase 1+ of local-first-onboarding, which needs the Client to be a normal swarm node hidden when childless and re-rendered when the user starts building locally — distinct from the always-visible avatar.

Root cause

NodeChildren.load(node: Node) dispatched on node.type and the KrillApp.Client arm returned a wide menu (Client.About, Server, Server.Peer, plus the union of every type currently in the swarm). That menu was the avatar menu in everything but name; it had nothing to do with “things you can attach to a Client node” — it was “things the user wants to poke from the avatar.” The arm even cleared its result when ClientMetaData.ftue == true, which was UI gating leaking into a sealed-type dispatch. Because KrillApp (the sealed root) is abstract, there was no way to address the avatar’s intent without piggybacking on a concrete subtype, and KrillApp.Client was the convenient hook.

Fix

Phase 0 of local-first-onboarding (krill#255, krill-oss#67) introduces a new sealed-root menu lookup and reduces the Client-node menu to its real shape. Three coordinated changes:

  1. shared/.../NodeChildren.kt gains an overload fun load(rootType: KClass<out KrillApp>): List<KrillApp> that returns the wide avatar menu when rootType == KrillApp::class and an empty list otherwise. The body is the same union-of-types-in-swarm computation that used to live in the KrillApp.Client arm of load(node). The KrillApp.Client arm itself is reduced to Project + MenuCommand.KeepBuildingSwarm — the focused per-node menu the design mandates. Update / Delete are intentionally absent (legacy behaviour for this type).

  2. shared/.../KrillApp.kt in krill-sdk 0.0.19 added MenuCommand.KeepBuildingSwarm as a new sealed subtype of MenuCommand (krill-oss#67). Every when (KrillApp) site in this repo had to grow a branch for it; the only one that wasn’t already exhaustive-via-else was IconManager.nodeIcon(node), which now maps KeepBuildingSwarm to the placeholder shrimp drawable per Ben’s note on the upstream issue (final icon to be supplied before Phase 2).

  3. composeApp/.../client/ClientScreen.kt routes the avatar’s speech-bubble menu computation through the new overload. The discriminator is node.id == installId() — the avatar is the only selection that can produce that id in Phase 0 (the Client node is not rendered in the graph until Phase 1 lights it up via NodeLayout). When that condition holds the speech bubble queries children.load(KrillApp::class); otherwise it stays on the legacy children.load(node) path.

ClientNodeManager.init() is unchanged — the Client node is still created on first launch with ClientMetaData(ftue = true) and kept as the durable home of the FTUE flag. The TOS dialog and PIN entry continue to gate the speech-bubble menu render at the UI layer, so the legacy if (meta.ftue) distinct.clear() defence inside the lookup wasn’t needed in either of the two new code paths.

Regression coverage lives in shared/src/jvmTest/.../NodeChildrenTest.kt (six cases): two for the new sealed-root API (sealed-root-only matching, and the swarm-walk inclusions

Prevention