Symptom

A nightly architectural bug-hunt flagged “inconsistent error handling” at the KrillApp dispatch seam, predicting that new KrillApp subtypes would fail silently in KrillApp.processor() while crashing in KrillApp.meta().

Most of that prediction was wrong, but the title was right for a different reason: client-side processor failures were being logged as a bare "Uncaught exception in IO_SCOPE" — no node id, no state, no stage — despite the seam appearing to have a guard.

Root cause

Two separate things, only one of them a defect.

Not a defect. KrillApp.processor() is a when expression over a sealed hierarchy, so exhaustiveness is compiler-enforced; a new subtype is a build break, not a silent runtime gap. (This repo was in exactly that state when the issue was worked: krill-sdk 0.0.63 added KrillApp.Swarm{Work,Batch} and the build failed to compile until the arms were added — the guard working as designed.) The asymmetry where processor() throws for Avatar / MenuCommand while meta() returns a placeholder is deliberate, documented, and pinned by KrillAppEmitExhaustiveTest + KrillAppMetaContractTest since #567: meta() is called on arbitrary types by node lists and editors that cannot guard, whereas reaching emit() with a UI-only type is a wiring bug that should be loud.

The actual defect. UniversalAppNodeProcessor.process() launches its entire body onto the shared IO_SCOPE and returns immediately. Every caller-side guard at the seam was therefore structurally incapable of catching a processor failure:

A guard placed on the near side of a launch boundary protects nothing on the far side. The seam looked defended and was not.

Fix

Added dispatchGuarded(node, stage, onFailure, block) in shared/src/commonMain/kotlin/krill/zone/shared/UniversalAppNodeProcessor.kt, stating the client dispatch error contract once: CancellationException is structural and always rethrown; any other failure is reported to an injected sink with the node’s identity and the stage that failed, and does not propagate. Applied it to process()’s launched body and to showActivity()’s inner launch (which now covers startPairing). onFailure is a parameter rather than a hardcoded logger call so the contract is unit-testable without Koin, a ClientNodeManager, or a logging backend. Also corrected the KDoc on KrillApp.emit() in KrillAppEmit.kt, which claimed the function was a “no-op when the type has no processor” and referenced a non-existent NodeProcessor.post — it throws, and the async caveat is now stated explicitly.

Prevention