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.
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:
getSelfWithInfo() is not a route, it is the server’s identity. Every
consumer of it inherits the throw simultaneously, including
ServerNodeManager.init().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.
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.
as on node.meta is never load-bearing-safe, because the
type/metadata pairing is a runtime convention, not a compile-time one. The
hierarchy is open interfaces; the DB is the trust boundary. docs/lessons/2026-07-14-lambda-input-local-only-blind-cast.md
is the same shape one module over. Use as? with an explicit branch.selfMeta(): ServerMetaData)
over documenting it and letting callers cast.when over an open hierarchy needs a test that walks the
hierarchy, not a comment saying “keep these in lockstep.” The new
NodeMetaDataSourceWiringExhaustiveTest derives its cases from allKrillApps
rather than restating the list a fourth time, so a node type added without
being wired into all three functions fails immediately. Verified by deleting
the LLMMetaData branch from withWiring and confirming the test names it.