Two adjacent files in shared/.../node/ handled the same situation — “this node’s
metadata is not the subtype I expected” — with opposite philosophies. Node.snapshot()
blind-cast (meta as DataPointMetaData) and threw ClassCastException; the wiring
functions next door safe-cast and returned the input unchanged. Neither reported the
common cause, and the crash landed inside executor coroutines rather than at the
boundary that accepted the bad node.
Separately, and found by running the test suite on main: SwarmWorkMetaData and
SwarmBatchMetaData had been added as SourceMetaData subtypes without being
enumerated in the three wiring whens, so Swarm Work and Swarm Batch nodes had a
silently read-only Sources tab. The drift guard added for #901
(NodeMetaDataSourceWiringExhaustiveTest) was red on main, catching exactly the
failure mode it was written for.
Node’s KDoc calls the type↔meta pairing an invariant — “the concrete subtype matches
type” — but nothing enforces it. POST /node/{id} deserializes the two independently:
the polymorphic discriminator lives inside the meta object and is never checked
against type. A body pairing "type": "DataPoint" with a SpacerMetaData payload
therefore parses, persists, and replays out of H2 without complaint. snapshot()
dispatched on type and then cast meta to whatever that discriminator implied, so
the first executor to read such a node as an input — SMTP, MQTT, outbound webhook, or
serial — died on the cast, in a coroutine, with no mention of the node id.
The else -> Snapshot() arm looked like a guard but was not: it only covers node types
that legitimately have no snapshot semantics. Every enumerated arm was its own
unguarded crash site.
snapshot() now routes each arm through a private reified helper,
Node.snapshotFrom<M> { … }, which safe-casts meta to M and, on mismatch, logs the
node id, its type, the expected subtype and the actual one before returning an empty
Snapshot. That is the contract NodeMetaDataSourceWiring.unenumeratedSourceMetaData
already documented for its own defect arm (#901) — safe cast, loud log, safe fallback —
so the two files now agree. Behaviour for correctly-typed nodes is byte-identical,
including the two arms that derive rather than read their snapshot (Server.Pin from
state, Trigger.CronTimer from timestamp). SwarmWorkMetaData and
SwarmBatchMetaData were added to all three wiring whens.
SnapshotTypeSafetyTest in shared/commonTest covers both directions.
when on a discriminator followed by a cast of a sibling field is a crash, not a
dispatch. The discriminator and the payload arrive over the wire independently;
only a schema check makes them agree, and Krill has none. Use a safe cast with a
logged fallback whenever the two are separate fields on the same wire type.shared/.../node/ is: safe cast,
log at error with enough identity to find the node, return the neutral value, and
back it with a test. Crashing a processor for one malformed node fails every other
node it was serving.allKrillApps instead of restating the when. A test that re-lists the
arms drifts exactly like the code it guards. SnapshotTypeSafetyTest pairs every
node type in the hierarchy with a deliberately wrong metadata subtype, so a new arm
added with a blind cast fails the moment it exists — the same shape as
NodeMetaDataSourceWiringExhaustiveTest.main is the guard working, not noise. The Swarm gap had
already shipped; the test written for the previous instance of this bug class named
the missing subtype precisely. Run the module’s suite before assuming a base branch
is green.