Symptom

Found by architectural scan, not by a field report — the crash needs a corrupt self node to trigger, and no one had hit it yet. Had it triggered, the symptom would have been a server that answers nothing: /nodes, /node/{id}, /node/{id}/qr, /health, the SSE stream and the discovery beacon all failing at once with ClassCastException, and no way to repair it through the API, because every repair path is one of the ones that crashes.

Root cause

ServerIdentity.getSelfWithInfo() resolved the server’s own metadata with an unchecked cast:

1
val meta = node.meta as ServerMetaData

node there is the row keyed by installId(), read straight out of H2. ExposedNodeRepository.read() only guards against JsonDecodingException, so a row that deserializes cleanly into some other NodeMetaData subtype reaches the cast intact. NodeMetaData and SourceMetaData are plain interfaces, not sealed, so nothing at compile time enforces that a Server-typed node carries ServerMetaData either.

Two things made a one-line cast into a whole-server outage:

  1. getSelfWithInfo() is not a route, it is the server’s identity. Every consumer of it inherits the throw simultaneously, including ServerNodeManager.init().
  2. The cast contradicted a contract the code already documented. A comment in ServerBeaconSender stated that getSelfWithInfo() “always returns a valid, port-resolved ServerMetaData — falling back to a freshly built default when our node has not been persisted yet.” That fallback existed for the absent self node. For the wrong-typed one, the same function threw. Five more call sites then re-derived the guarantee from outside with their own getSelfWithInfo().meta as ServerMetaData, so the risk was invisible at every one of them.

The same self node was blind-cast at four further sites that read it back through ServerNodeManager instead (/nodes, /node/{id}, the SSE open-handler, /health) plus Lifecycle.updateServerInfo(), whose throw would have silently stopped the version / os / model / camera fields from ever refreshing.

The related half of the same inconsistency lived in NodeMetaDataSourceWiring.kt: withParentSourceDefault, withWiring and withInputs each hand-enumerate every SourceMetaData subtype, and their else arm returned the input unchanged. That arm is unreachable for non-targeting metadata — the early as? SourceMetaData ?: return this already handled it — so reaching it always meant a subtype was missing from the when, and the only consequence was that the node’s wiring became silently uneditable. LLMMetaData had already shipped exactly that bug.

Fix

Extracted resolveServerMetaData(meta, hostName, platform, demoMode) in server/src/jvmMain/kotlin/krill/zone/server/ServerIdentity.kt — a pure function that safe-casts, logs an error naming the actual subtype, and rebuilds from ServerMetaData() defaults, which is the same shape getSelfWithInfo() already produced for a never-persisted self node. Added ServerIdentity.selfMeta(): ServerMetaData and used it at the five sites in Routes.kt and io/beacon/ServerBeaconSender.kt that previously re-cast, so the guarantee is produced once by the object responsible for it instead of asserted by each caller. The four remaining self-node reads in Routes.kt and Lifecycle.kt now degrade to selfMeta() via as? ... ?: rather than throwing. In shared/src/commonMain/kotlin/krill/zone/shared/node/NodeMetaDataSourceWiring.kt the three else arms now route through unenumeratedSourceMetaData(), which still returns the value unchanged — throwing there would take down node creation and the editor over one missing branch — but logs the missing subtype by name.

Prevention