CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti
Implementation reference for the digital-twin. For its architectural role, see Digital Twin Service Architecture; for the domain model, see Building Management.
A Rust / Axum service. It is the spatial source of truth: it stores buildings and rooms, and on every change it synchronises a lightweight clone to the dependent services — asynchronously over Kafka for registration, synchronously over REST for later edits (see Downstream Synchronisation below). The room set is write-once: the upload creates it and nothing edits it afterwards, so the twin cannot drift from the model it was built from.
| Path | Responsibility |
|---|---|
src/lib.rs | Builds the axum::Router (public infra routes + protected building routes + rate-limit/metrics layers). Reused by both main.rs and the integration test suite. |
src/main.rs | Reads env config, connects Mongo, spawns the provisioning worker, starts the Axum server with graceful shutdown. |
src/adapters/driving/http_api/controllers.rs | Every building HTTP handler. Each one does the same three things: turn wire input into domain types, call a use case, map the result back to HTTP. No handler reaches a database or a downstream service. |
src/adapters/driving/http_api/claims.rs | The Axum FromRequestParts extractor that decodes the base64 x-gateway-claims header into the core’s GatewayClaims. Decoding is delivery-specific; the identity it produces is not. |
src/adapters/driving/http_api/exceptions.rs | The single DomainError → HTTP response mapping. Lives here, not in the core, so domain/ stays free of Axum. |
src/domain/ | Building/Room/Coordinates/Dimensions, the lenient PositionInput/DimensionsInput request types, AcceptedUpload/UploadStatus, GatewayClaims/ClaimsPayload/Membership, name-normalisation helpers, and DomainError. Imports neither axum nor mongodb. |
src/service/ports.rs | The four traits the use cases require of the outside world: BuildingStore, UploadQueue, DownstreamSync, RegistrationEvents. Defined by the core, implemented in adapters/driven/. |
src/service/buildings.rs | Every use case that acts on an existing twin — read, list, count, resolve domains, update. Rooms have no use cases of their own: they are part of a building, written once by the upload and read-only afterwards. |
src/service/authz.rs | Cedar authorization (is_member_of, can_edit_domains, scope_to_memberships) against the shared backend/libs/auth-policy bundle. In the core, not in adapters/driven/: whether a caller may edit a building is a rule, and Cedar is a pure embedded evaluator with no I/O. |
src/service/fakes.rs | #[cfg(test)] in-memory stand-ins for all four ports, shared by the use-case tests — the Rust counterpart of the Go services’ storefake sibling. |
src/service/provisioning.rs | The accept-and-provision use case: accept (durable enqueue, runs in the request), provision_next (claim-and-publish, runs in the worker), resolve (turns telemetry’s own outcome into ready/failed, runs in the Kafka consumer), status. Depends only on the ports, so its unit tests need neither a database nor a network. |
src/adapters/driving/worker.rs | An in-process Tokio loop that calls provision_next until told there is nothing to do. Names no queue and no database. |
src/adapters/driving/kafka_consumer.rs | Consumes building-registration-completed and calls provisioning.resolve — the third driving adapter for the same provisioning use case, alongside the HTTP handler and the worker. |
src/adapters/driven/persistence/db.rs | MongoDB access over the Building collection, plus MongoBuildings — the BuildingStore adapter. |
src/adapters/driven/persistence/jobs.rs | MongoUploadQueue, the UploadQueue adapter over pending_uploads: lease-based claim, mark_ready/mark_failed, status lookup. The lease and attempt counters live in the document, never in the core. |
src/adapters/driven/outbound.rs | Fire-and-forget/best-effort REST calls: default-preference seeding on dashboard, the threshold clone on telemetry for a later building update (not registration, which moved to Kafka), and the provisioning-failure alert on notification (POST /trigger, as a system caller — no end user to forward claims from). OutboundConfig is itself the DownstreamSync adapter. |
src/adapters/driven/kafka_producer.rs | KafkaEventProducer, the RegistrationEvents adapter: publishes building-registration-requested, ensuring both registration topics exist first (auto-created topics are lazy — the first-ever produce/subscribe against one can otherwise race the broker’s own creation of it). |
src/adapters/metrics.rs | Prometheus registry, the track_metrics layer, and the /health//metrics handlers. Cross-cutting — neither driving nor driven. |
src/adapters/ratelimit.rs | Per-IP fixed-window rate limiter. Cross-cutting, same as metrics.rs. |
tests/http.rs | Full HTTP-level suite (tower::ServiceExt::oneshot against the real Router) covering every route. |
tests/cucumber.rs + tests/features/ | The Gherkin acceptance criteria for building provisioning, run by cucumber-rs against the same in-process router. See Acceptance Criteria. |
tests/cedar_conformance.rs | Runs backend/libs/auth-policy/fixtures/conformance.json’s 22 golden cases through the real cedar-policy engine. |
For the conceptual view — which module is core, which is a port, which is an adapter, and why — see Digital Twin Service Architecture. What follows is the wiring as it actually exists in the crate: concrete types, the layer each one sits in, and what calls what at run time.
graph TB
REQ(["HTTP request"])
TICK(["Tokio tick"])
KAFKAIN(["Kafka message"])
subgraph l1["LAYER 1 — driving adapters"]
ROUTER["lib.rs\nbuild_router assembles the Router"]
MW["adapters/metrics.rs · ratelimit.rs\nRouter::layer, cross-cutting"]
EXTRACT["adapters/driving/http_api/claims.rs\nFromRequestParts for GatewayClaims"]
HANDLERS["adapters/driving/http_api/controllers.rs\n8 handlers"]
ERRMAP["adapters/driving/http_api/exceptions.rs\nIntoResponse for DomainError"]
WORKER["adapters/driving/worker.rs\nspawn: loop over provision_next"]
KCONS["adapters/driving/kafka_consumer.rs\nspawn: consume, call resolve"]
end
subgraph l2["LAYER 2 — core, use cases (service/)"]
UCB["Buildings\nget · list_for_domain · counts · domains_of\nupdate"]
UCP["Provisioning\naccept · provision_next · resolve · status"]
AUTHZ["authz.rs\nis_member_of · can_edit_domains\nscope_to_memberships"]
end
subgraph l2p["LAYER 2 — core, ports (service/ports.rs)"]
PS["trait BuildingStore"]
PQ["trait UploadQueue"]
PD["trait DownstreamSync"]
PE["trait RegistrationEvents"]
end
subgraph l1e["LAYER 1 — core, entities (domain/)"]
ENT["Building · Room · Coordinates · Dimensions\nGatewayClaims · AcceptedUpload · UploadStatus\nDomainError"]
end
subgraph l3["LAYER 3 — driven adapters (adapters/driven/)"]
IDB["persistence/db.rs\nMongoBuildings"]
IJOB["persistence/jobs.rs\nMongoUploadQueue"]
IOUT["outbound.rs\nOutboundConfig"]
IKPROD["kafka_producer.rs\nKafkaEventProducer"]
end
subgraph l4["LAYER 4 — frameworks and drivers"]
MONGO[("twin-db\nbuildings · pending_uploads")]
SENSOR["telemetry"]
CONTRACTS["dashboard"]
KAFKA[("Kafka\nbuilding-registration-requested/-completed")]
POLICY["backend/libs/auth-policy\npolicy.cedar + schema"]
end
REQ --> ROUTER --> MW --> EXTRACT --> HANDLERS
HANDLERS --> ERRMAP
TICK --> WORKER
KAFKAIN --> KCONS
HANDLERS --> UCB
HANDLERS --> UCP
WORKER --> UCP
KCONS --> UCP
UCB --> AUTHZ
UCB --> PS
UCB --> PD
UCP --> PS
UCP --> PQ
UCP --> PD
UCP --> PE
UCB --> ENT
UCP --> ENT
HANDLERS --> ENT
IDB -.->|impl| PS
IJOB -.->|impl| PQ
IOUT -.->|impl| PD
IKPROD -.->|impl| PE
IDB --> MONGO
IJOB --> MONGO
IOUT --> SENSOR
IOUT --> CONTRACTS
IKPROD --> KAFKA
KAFKA --> SENSOR
SENSOR --> KAFKA
AUTHZ -.->|"include_str!, compile time"| POLICYTwo things to read off it. First, adapters/driving/http_api, adapters/driving/worker.rs, and adapters/driving/kafka_consumer.rs all stop at layer 2 — none has an edge into layer 3, which is the grep the crate enforces. Second, the only solid arrows crossing from layer 3 into layer 2 are the dashed impl ones, pointing at traits: adapters/driven depends on service, never the reverse. Note that Provisioning is now entered from three directions (HANDLERS, WORKER, KCONS) rather than two — the same use case, three ways in.
service/ports.rs is defined by the use cases, not by the adapters that satisfy it — which is the whole difference between this and a db module everyone imports. Ports return anyhow::Error so no port ever has to describe how its adapter failed; DomainError absorbs it at the use-case boundary via From<anyhow::Error>. AppState carries use cases rather than a Collection<Building> and a reqwest::Client, so a handler physically cannot reach a database — there is no field to reach it through.
| Variable | Default | Purpose |
|---|---|---|
MONGO_URI | mongodb://localhost:27017 | Connection string; database name is crowdvision, collections buildings and pending_uploads (the provisioning queue, which always lives beside the buildings it provisions). |
TELEMETRY_URL | http://localhost:3000 | Target for the threshold clone sync on later edits (not registration — see Downstream Synchronisation). |
DASHBOARD_URL | http://localhost:3001 | Target for default-preference seeding. |
KAFKA_BROKERS | localhost:9092 | Broker list for the building-registration-requested/-completed topics. |
PORT | 3000 | HTTP listen port. |
NODE_ENV | — | When test, threshold sync, preference seeding, and the rate limiter are all disabled. |
struct Building {
id: String, // server-generated UUID (uuid::Uuid::new_v4); unique
name: String,
rooms: Vec<Room>,
domains: Vec<String>,
}
struct Room {
id: String, // client-supplied on /register
name: String,
capacity: f64,
position: Coordinates, // { x, y, z }
dimensions: Dimensions, // { width, height, depth }
color: Option<String>,
}id is generated server-side on POST /register; a client-supplied one is ignored. Room ids are client-supplied and come from the uploaded description — they are the only ids a client chooses, and there is no later route to change them.id and a non-unique index on domains (Building.find({ domains: X }) stays an index seek).pending_uploads queue carries a unique index on id (status polls and resolves) and one on status + leased_until (the worker’s claim).finished_at, and a TTL index removes them an hour later. The record must outlive the browser’s status poll (up to 30 s), or a successful upload times out; deleting on completion fails every such poll. MongoDB’s TTL sweep runs every 60 s, so removal lags by up to a minute.finished_at onto uploads finished before expiry existed; a TTL index never removes a record without its field._id, a document version, timestamps) leak into API responses — the Building/Room structs only carry fields a client actually reads.| Method · Path | Description |
|---|---|
POST /register | Accept a building description { name, rooms, domains } for provisioning. Assigns a UUID, normalises names, validates room geometry (see the box below), then durably enqueues it. Returns 202 with { buildingId } — the tracking handle — without waiting for the twin to be built. A malformed description is refused 400 and never enqueued. |
GET /building/:id/status | Report an accepted upload’s progress as { status } — pending, ready, or failed. 404 for an unknown handle. Poll this after a 202 to know when the twin is viewable. |
POST /building/:id/sync | Re-publish this building’s registration request on building.registration.requested, so telemetry (and any other consumer) re-registers it. Idempotent, writes nothing in digital-twin. Requires an editing role in one of the building’s domains; 404 if the building is absent. The backfill path — see the box below. |
GET /building/:id | Fetch one building by UUID; backfills names on read. 404 if absent. No domain-membership check beyond authentication — building ids are server-generated UUIDs, not enumerable. |
GET /buildings/:domain | List buildings scoped to a tenant (domains contains :domain); requires membership in that domain (403 otherwise). Returns [] when none. |
POST /buildings/counts | Building counts per requested domain, silently dropping domains the caller isn’t a member of. 400 if domains isn’t an array of strings, or exceeds 500 entries. |
GET /domain/:buildingName | Return the flattened list of domains for buildings matching the name. Used by notification for recipient resolution. |
PATCH /building/:buildingId | Update name and/or domains (and an optional maxTemperature forwarded to the clone). Requires an editing role in one of the building’s own domains. Re-syncs. |
GET /contracts | Return this service’s metric catalog (roomName, roomMaxOccupancy). Public. |
GET /health, GET /metrics | Liveness and Prometheus metrics. Public. Registered both with and without a trailing slash — see the gotcha box on Kubernetes Configuration. |
Istio’s RequestAuthentication verifies the caller’s JWT once at the ingress and injects the payload as the base64 x-gateway-claims header (outputPayloadToHeader) — digital-twin never verifies a JWT itself. Every protected handler takes a GatewayClaims argument (an Axum extractor, src/adapters/driving/http_api/claims.rs); extraction itself 401s the request if the header is missing or malformed, so there’s no separate auth middleware layer to wire in — the type signature is the auth gate. Geometry-mutating routes additionally require can_edit_domains (Cedar Edit action) against the target building’s own domains, not just any domain membership.
A consumer that lost its state — a rebuilt telemetry database, a service that was down, a rewrite that started with an empty schema — cannot replay the topic: the registration consumer runs with auto.offset.reset=latest and so never sees messages published before it joined. POST /building/:id/sync is the answer: it reads the stored building and publishes exactly the payload provisioning would have published, so every consumer re-registers it. It is not a repair for a missing twin — the building must already exist here.
/register validates every room’s position, dimensions, and capacity before persisting — a malformed room is rejected with a 400 Validation Error, never silently stored. It is the only route that writes rooms, so this is the only place that validation has to happen. Validation deliberately stays synchronous, ahead of the 202: a description the service would refuse must never be acknowledged as accepted. Only the work that can’t fail on the caller’s input moves to the worker.
Free functions in src/domain/building.rs.
normalize_building_name(name, id) resolves to the trimmed name, else id, else the literal "Building".normalize_room_name(name, id) trims the room name, falling back to the room id.GET, and every mutating handler’s initial fetch) backfills blank names and persists the fix if anything changed — so a document with a blank name gets healed the next time it’s touched, without a separate migration.A building’s structure is mirrored to telemetry and dashboard, but not the same way: registration announces over Kafka and waits on telemetry’s own outcome; a later building update (rename, re-domain, max-temperature) still calls telemetry directly over REST, synchronously, in-request. Contracts-service preference seeding is REST and best-effort in both cases.
sequenceDiagram
autonumber
participant C as Client
participant T as digital-twin (API)
participant Q as pending_uploads
participant W as digital-twin (worker)
participant DB as twin-db
participant K as Kafka
participant S as telemetry
participant KC as digital-twin (kafka_consumer)
participant Ctr as dashboard
C->>T: POST /register { name, rooms, domains }
Note over T: normalise names, validate geometry, assign UUID
T->>Q: enqueue PendingUpload (durable)
T-->>C: 202 { buildingId }
W->>Q: claim (lease, atomic)
W->>DB: upsert
W->>K: publish building-registration-requested
Note over W,K: publish returns immediately -- does not wait on telemetry
W->>Ctr: POST /preferences/init/:id
Note over W,Ctr: best-effort — errors logged, swallowed
K->>S: building-registration-requested
S->>S: write threshold clone (upsert)
S->>K: publish building-registration-completed { status: "ready" }
K->>KC: building-registration-completed
KC->>Q: resolve -- mark ready
C->>T: GET /building/:id/status
T-->>C: { status: "ready" }KafkaEventProducer::publish_requested, src/adapters/driven/kafka_producer.rs): publishes building-registration-requested keyed by buildingId, carrying { buildingId, name, rooms: [{ id, name }] }. A publish failure fails the provisioning job the same way a REST failure used to; a successful publish does not — the upload stays pending until telemetry’s own completion event resolves it.adapters/driving/kafka_consumer.rs): consumes building-registration-completed, calling resolve(buildingId, error), which is what actually marks the upload ready or failed. Idempotent under redelivery — resolving the same id twice sets the same status twice.sync_building_clone, src/adapters/driven/outbound.rs): a PUT carrying { name, rooms: [{ id, name }], maxTemperature? }. Skipped entirely under NODE_ENV=test, and returns an error on a non-2xx response — which fails the mutating request itself, since the route is already synchronous. Forwards the caller’s x-gateway-claims header verbatim — telemetry reads the same mesh-injected identity twin trusts; mTLS already authenticates the hop, the header just carries who the original caller was.init_building_preferences): a POST, best-effort — logged and swallowed on any error, never blocks building creation.Provisioning::fail, private, called by both provision_next and resolve): marks the job failed, calls notification’s POST /trigger (notify_provisioning_failed, src/adapters/driven/outbound.rs) as a fixed system caller — same pattern as notification’s own event-listener path, see Notification Service Architecture — then deletes the twin (BuildingStore::delete). Both steps are skipped unless mark_failed actually claimed a still-pending upload — a failure reported for an upload that was already resolved is logged and dropped, so a re-announced building (POST /building/:id/sync) that a consumer rejects can never delete a live twin. Notify happens before delete: /trigger resolves the building’s domains by calling back into digital-twin’s /domain/:building, so the twin must still exist when that call lands. The HTTP call is best-effort — logged and swallowed on error, never blocks the failure resolution itself.A worker holds a job under a 30s lease. If it dies mid-provision the lease expires and another worker claims the same job — so provision must converge, not collide. The tracking handle is the building id, the Mongo write is an upsert, and both mark_ready/mark_failed match on status: "pending", so only the first resolution of an upload lands. Re-running a job, or redelivering a Kafka message on either side, converges on the same outcome rather than a second one: publish_requested might fire twice and telemetry’s registration is itself an upsert. A redelivered failure matches nothing, so it neither deletes nor notifies a second time. Failure handling today is terminal (status: "failed", no retry budget); a retry policy and dead-letter threshold are still to come.
DomainError (src/domain/error.rs) is mapped to HTTP by src/adapters/driving/http_api/exceptions.rs’s IntoResponse impl, mapping to a stable { type, message } JSON shape and status code: Validation → 400, NotFound → 404, Unauthorized → 401, Forbidden → 403. Unexpected errors (a Mongo failure, a Cedar entity-build failure) are logged via log::error! and mapped to a generic 500 — every error is logged, and no internal detail leaks past the generic 500 message.
track_metrics is an Axum Router::layer, which wraps the entire request/response cycle regardless of how the inner handler responds — MatchedPath (the route template, e.g. /building/{id}, not the literal path with real ids) is available to layers added this way, so every registered route is observed regardless of the response it produces. Verified live: registering a building and then curling /metrics shows real http_requests_total/http_request_duration_seconds/http_error_requests_total series with the correct method/route/status_code labels.
src/service/authz.rs embeds the shared backend/libs/auth-policy bundle via include_str! — resolved at compile time, the same way Go’s //go:embed does. Only Python’s binding reads schema.cedarschema/policy.cedar from disk, at process start, since it has no equivalent embed mechanism. Compile-time embedding here means the production Docker image doesn’t need backend/libs/auth-policy present at all; only the builder stage does, since the bytes are already baked into the compiled binary by the time cargo build finishes. See Auth Policy for the full Cedar model (pre-expanded role-tier sets, .contains() vs in, the golden conformance suite).
digital-twin uses two of Cedar’s five shared actions: Read (is_member_of/scope_to_memberships) and Edit (can_edit_domains, tried against every one of a building’s domains — permits if any qualifies). A third function, authorize_any, exists purely so the golden conformance suite can exercise all five actions through this binding too, matching the precedent set by agent’s can_override_model.