CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti
telemetry-schema is the crate the whole shared-contracts effort started from. It exists because of a bug: telemetry served a metric catalog with {key, kind} and dashboard parsed {metricKey, type}. The response type was an untagged enum, so neither variant matched, and the parse failure was treated as “that service is down, skip it” — an empty catalog returned with 200 OK, and nobody could add or swap a dashboard column. No error anywhere.
It now carries three contracts, each one a place where the same class of failure was possible.
| Module | Shape | Producer | Consumers |
|---|---|---|---|
lib.rs | Metric catalog (ServiceMetricsContract, MetricContract, …) | telemetry /contracts, digital-twin /contracts | dashboard, frontend |
alerts.rs | AlertEvent + ALERTS_TOPIC | telemetry | notification |
telemetry.rs | TelemetryEnvelope, TelemetryReading, both channel names | telemetry | dashboard, socket |
The original contract, unchanged: serde structs both services build from, with rename_all = "camelCase" and field names that frontend/src/models/table.ts also reads. The rule that came out of the incident is the one every module here follows — the producer builds the struct; nobody hand-rolls json! for a shape another service parses.
The seam is covered by a test that lives on the producing side: telemetry/tests/api.rs::the_catalog_deserialises_into_the_shape_dashboard_parses.
AlertEvent — a value keyed by the field that breached{"buildingId":"b1","roomId":"r1","buildingName":"HQ","roomName":"Lab 2","co2":1200.0,
"type":"airQuality","field":"co2","label":"CO2","unit":"ppm","direction":"high",
"threshold":1000.0,"timestamp":1700000000000}type names the metric; field names the payload field whose bound broke, and the value sits under a key of that name. One metric can bound several fields — air quality bounds co2 and indoor_aqi — so the metric alone cannot say what crossed.Serialize/Deserialize are hand-written rather than derived — a derive cannot express “look up the key this other field names”. A field naming a key the object does not carry is a parse error, not a silent None.label and unit come from telemetry’s bound, so a consumer renders any metric without its own table. unit is omitted when the field has none (AQI).buildingName and roomName are display names from twin’s registration, filled by telemetry from its own projection; an unregistered room gets its id. The ids stay the keys: names can repeat, and twin’s domain lookup falls back to a name match across every building sharing it.field, label, unit and the names are optional on read only: a record written before they existed reads as field = label = the metric, and each name = its id.threshold is part of the type because it was already on the wire and being thrown away.ALERTABLE_METRICS is the set notification deliverstemperature, airQuality, peopleCount. telemetry’s plugins::all() test pins it to exactly the plugins that declare bounds, and notification uses it as its preference types, so a new bounded plugin fails a test until it joins. Anything else gets BreachOutcome::Unsupported — logged with the metric name, counted unsupported_metric, settled, never silently skipped.
TelemetryEnvelope — a tick, and the channels it travels onOne building tick is one message, end to end:
{"buildingId":"b1","ingestedAt":1700000000500,
"readings":[{"type":"temperature","roomId":"r1","timestamp":1700000000000,"value":21.5}]}The shape is pinned in schemas/fixtures/telemetry-envelope.json (+ its JSON schema): this crate round-trips every tick and reading, telemetry’s redis_fanout.rs must publish each case byte for byte, and the frontend reads a tick only through src/utils/telemetry.ts (readingsOf, readingsFor), whose spec replays the same file. The envelope has no type — only its readings do — so a reader that treats a tick as one reading drops every update.
pub struct TelemetryReading {
pub metric: String, // "type"
pub room_id: String,
pub ts_ms: i64, // "timestamp"
pub value: f64,
#[serde(flatten)]
pub fields: Map<String, Value>,
}readings is Vec<Value> on the envelope, deliberately. A reading’s fields belong to whichever plugin produced it — telemetry is a microkernel, and enumerating plugin fields in a shared crate would undo that. TelemetryReading types the part that is fixed (the four names the browser reads; buildingId and ingestedAt are stated once, on the envelope) and flattens the rest, so a reading round-trips whatever its plugin emitted.
dashboard parses the envelope to route it, then republishes the exact bytes it received. Routing is a decision about buildingId; it is not a licence to rebuild a payload this service does not own. It used to deserialise to a Value and re-serialise, which was both slower and one refactor away from dropping a field.
ingestedAt is now required. It was optional before — used only for the fan-out latency histogram, skipped when absent. One service produces this envelope and always sets it, so a missing one means a broken publisher, and forwarding a broken tick is precisely the failure this crate exists to stop.
pub const RAW_CHANNEL: &str = "telemetry:raw";
pub const FILTERED_CHANNEL_PATTERN: &str = "telemetry:filtered:*";
pub fn filtered_channel(building_id: &str) -> String;
pub fn building_of_filtered_channel(channel: &str) -> &str;Four declaration sites across three services collapse into these. The pair matters more than it looks: dashboard writes telemetry:filtered:{id} and socket reads the building back out of it, so the prefix is a two-way contract, and a building id containing a colon (site:b1) has to survive the round trip. There is a test for exactly that.
Every consumer builds from repo-root context so the sibling path dependency resolves. .github/services.json carries cd_context: "." and an explicit dockerfile for each, and every Dockerfile copies the crate before cargo chef prepare and before each cargo chef cook — cook restores the dependency graph, and a path dependency that is not on disk at that moment fails the build halfway through.