/

CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti

Digital Twin Service

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.


Source Layout

PathResponsibility
src/lib.rsBuilds 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.rsReads env config, connects Mongo, spawns the provisioning worker, starts the Axum server with graceful shutdown.
src/adapters/driving/http_api/controllers.rsEvery 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.rsThe 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.rsThe 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.rsThe 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.rsEvery 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.rsCedar 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.rsThe 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.rsAn 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.rsConsumes 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.rsMongoDB access over the Building collection, plus MongoBuildings — the BuildingStore adapter.
src/adapters/driven/persistence/jobs.rsMongoUploadQueue, 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.rsFire-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.rsKafkaEventProducer, 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.rsPrometheus registry, the track_metrics layer, and the /health//metrics handlers. Cross-cutting — neither driving nor driven.
src/adapters/ratelimit.rsPer-IP fixed-window rate limiter. Cross-cutting, same as metrics.rs.
tests/http.rsFull 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.rsRuns 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"| POLICY

Two 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.

The core owns its interfaces

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.


Configuration

VariableDefaultPurpose
MONGO_URImongodb://localhost:27017Connection string; database name is crowdvision, collections buildings and pending_uploads (the provisioning queue, which always lives beside the buildings it provisions).
TELEMETRY_URLhttp://localhost:3000Target for the threshold clone sync on later edits (not registration — see Downstream Synchronisation).
DASHBOARD_URLhttp://localhost:3001Target for default-preference seeding.
KAFKA_BROKERSlocalhost:9092Broker list for the building-registration-requested/-completed topics.
PORT3000HTTP listen port.
NODE_ENVWhen test, threshold sync, preference seeding, and the rate limiter are all disabled.

Data Model

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>,
}

HTTP API

Method · PathDescription
POST /registerAccept 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/statusReport 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/syncRe-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/:idFetch 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/:domainList buildings scoped to a tenant (domains contains :domain); requires membership in that domain (403 otherwise). Returns [] when none.
POST /buildings/countsBuilding 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/:buildingNameReturn the flattened list of domains for buildings matching the name. Used by notification for recipient resolution.
PATCH /building/:buildingIdUpdate 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 /contractsReturn this service’s metric catalog (roomName, roomMaxOccupancy). Public.
GET /health, GET /metricsLiveness and Prometheus metrics. Public. Registered both with and without a trailing slash — see the gotcha box on Kubernetes Configuration.

Every building route requires the mesh-verified claims header

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.

Re-registering a building downstream

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.

Registration validates room geometry

/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.


Name Normalisation

Free functions in src/domain/building.rs.


Downstream Synchronisation

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" }

Provisioning is at-least-once, so the work is idempotent

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.


Error Handling

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.


Metrics

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.


Cedar Authorization

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.