CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti
twin-schema holds the two Kafka messages digital-twin and telemetry exchange when a building is registered, and the names of the topics that carry them. Before it, each message was hand-rolled as json! on one side and read as an untyped serde_json::Value on the other — the arrangement that produced the empty dashboard catalog that Telemetry Schema was created to prevent.
sequenceDiagram
participant T as digital-twin
participant K as Kafka
participant TS as telemetry
T->>K: building-registration-requested<br/>RegistrationRequest
K->>TS: consume, keyed by buildingId
TS->>TS: upsert building + rooms, seed thresholds
TS->>K: building-registration-completed<br/>RegistrationCompleted
K->>T: consume, resolve the uploadTopic constants live beside the payload that travels on them — telemetry-schema owns ALERTS_TOPIC the same way. Each service re-exports both from its own adapters/topics.rs, so the string itself is written once:
// digital-twin/src/adapters/topics.rs
pub use twin_schema::{
BUILDING_REGISTRATION_COMPLETED_TOPIC, BUILDING_REGISTRATION_REQUESTED_TOPIC,
};Three services had previously declared these strings independently.
RegistrationRequest — leniency where it belongspub struct RegistrationRoom {
pub id: String,
pub name: String,
}
pub struct RegistrationRequest {
pub building_id: Option<String>,
pub name: String,
pub max_temperature: Option<f64>,
pub rooms: Vec<RegistrationRoom>,
}The rooms list deserialises leniently, which is the behaviour telemetry already had as hand-written Value walking:
fn usable_rooms<'de, D>(deserializer: D) -> Result<Vec<RegistrationRoom>, D::Error> {
let entries = Option::<Vec<Value>>::deserialize(deserializer)?.unwrap_or_default();
Ok(entries
.into_iter()
.filter_map(|entry| {
let id = entry.get("id")?.as_str().filter(|id| !id.is_empty())?;
let name = entry.get("name").and_then(Value::as_str)
.filter(|name| !name.is_empty()).unwrap_or(id);
Some(RegistrationRoom { id: id.to_owned(), name: name.to_owned() })
})
.collect())
}A room with no id cannot be stored or addressed, so it is dropped; a room with no name takes its id. Failing the whole building over one unusable room would deny a building for a room nobody asked about.
telemetry seeds a building temperature bound from it. digital-twin does not populate it — it syncs thresholds over HTTP (service/buildings.rs::clone_thresholds) instead. Typing the payload is what made that visible; the field stays Option rather than being deleted, because the consumer’s behaviour is unchanged and a replayed older message may still carry it.
name is #[serde(default)] rather than required, so an empty one reaches telemetry’s own check and produces its exact message — name: must be a non-empty string. That message is not decoration: it travels back on the completion event and becomes the failure reason on the user’s upload.
RegistrationCompleted — an unknown status is a failurepub struct RegistrationCompleted {
pub building_id: String,
pub status: String,
pub error: Option<String>,
}
impl RegistrationCompleted {
pub fn ready(building_id: &str) -> Self { /* status: "ready" */ }
pub fn failed(building_id: &str, error: &str) -> Self { /* status: "failed" */ }
pub fn failure(&self) -> Option<String> {
(!self.is_ready())
.then(|| self.error.clone().unwrap_or_else(|| self.status.clone()))
}
}status stays a String rather than becoming an enum on purpose. An enum would make an unrecognised status a parse failure, and a parse failure on this topic means the consumer logs and skips — leaving the user’s upload stuck at “pending” forever. failure() instead treats anything that is not ready as failed, naming the unrecognised status as the reason.
Building, Room, Coordinates and Dimensions stay in digital-twin/src/domain/. The issue that prompted this crate assumed telemetry parsed twin’s building payload over HTTP; it does not — its only call, GET /domain/{id}, returns a bare Vec<String> of domain names. Moving twin’s own domain types into a shared crate would have produced a dependency with exactly one consumer.
The services that do read the full building are agent (Python) and the frontend (TypeScript), and no Rust crate can help either. What holds those three in line is a fixture:
| Language | Asserts |
|---|---|
| Rust | digital-twin/tests/building_conformance.rs — parses schemas/fixtures/building.json into the real Building, then round-trips it back to JSON and compares |
| Python | agent/tests/unit/test_building_conformance.py — runs the same file through _room_payload/_building_payload, the projections the twin tools hand to the model |
The round-trip half is the one that matters: it fails when twin’s type stops carrying a field the fixture has, which is precisely the drift a Python consumer would otherwise discover as a None inside a tool result.