/

Developer Guide

Digital Twin Service

Implementation reference for the twin-service. 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 (see Downstream Synchronisation below — the two services that receive that clone, and what happens if sending it fails).


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, starts the Axum server with graceful shutdown.
src/api/buildings.rsEvery building/room HTTP handler, matching this repo’s existing Rust convention of keeping HTTP handlers in one module (see contracts-service).
src/models.rsBuilding/Room/Coordinates/Dimensions, the lenient PositionInput/DimensionsInput request types, name-normalisation helpers, and the AppError → HTTP response mapping.
src/infra/db.rsMongoDB access (find/insert/replace/aggregate) over the Building collection.
src/infra/claims.rsGatewayClaims, an Axum extractor that decodes the x-gateway-claims header — the mesh-injected identity every protected handler requires as an argument (extraction itself 401s on a missing/invalid header, so there’s no separate auth middleware layer).
src/infra/authz.rsCedar authorization (is_member_of, can_edit_domains, scope_to_memberships) against the shared backend/auth-policy bundle.
src/infra/outbound.rsFire-and-forget/best-effort sync calls to sensor-service and contracts-service.
src/infra/metrics.rsPrometheus registry, the track_metrics layer, and the /health//metrics handlers.
src/infra/ratelimit.rsPer-IP fixed-window rate limiter.
tests/http.rsFull HTTP-level suite (tower::ServiceExt::oneshot against the real Router) covering every route.
tests/cedar_conformance.rsRuns backend/auth-policy/fixtures/conformance.json’s 22 golden cases through the real cedar-policy engine.
graph TD
    MAIN["main.rs\nentry point"] --> LIB["lib.rs\nbuilds the Router"]
    LIB --> API["api/buildings.rs\nall HTTP handlers"]
    API --> CLAIMS["infra/claims.rs\nGatewayClaims extractor"]
    API --> AUTHZ["infra/authz.rs\nCedar Read/Edit checks"]
    API --> DB["infra/db.rs\nMongo access"]
    API --> OUT["infra/outbound.rs\nsensor/contracts sync"]
    API --> MODELS["models.rs\nBuilding/Room, AppError"]
    LIB --> METRICS["infra/metrics.rs\nPrometheus layer"]
    LIB --> RATE["infra/ratelimit.rs\nper-IP limiter"]
    AUTHZ -.->|"include_str!, compile time"| POLICY["backend/auth-policy\npolicy.cedar + schema"]

Configuration

VariableDefaultPurpose
MONGO_URImongodb://localhost:27017Connection string; database name is crowdvision, collection buildings.
SENSOR_SERVICE_URLhttp://localhost:3000Target for the threshold clone sync.
CONTRACTS_SERVICE_URLhttp://localhost:3001Target for default-preference seeding.
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 and PUT rooms; server-generated on POST room
    name: String,
    capacity: f64,
    position: Coordinates,     // { x, y, z }
    dimensions: Dimensions,    // { width, height, depth }
    color: Option<String>,
}

HTTP API

Method · PathDescription
POST /registerCreate a building from { name, rooms, domains }. Assigns a UUID, normalises names, validates room geometry (see the box below), persists, then synchronises downstream. Returns the full document. 201.
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-service 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.
PATCH /building/:buildingId/room/:roomIdUpdate a room’s name, color, capacity, position, and/or dimensions. Re-syncs.
POST /building/:buildingId/roomCreate a room with a server-assigned id. Re-syncs, then best-effort seeds default sensor thresholds.
DELETE /building/:buildingId/room/:roomIdDelete a room; blocked if it’s the building’s last room (400).
PUT /building/:buildingId/roomsAtomically replace the whole rooms array — the editor’s bulk-save path. Validates every room before writing any of them (no partial write on a bad room).
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/room 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) — twin-service never verifies a JWT itself. Every protected handler takes a GatewayClaims argument (an Axum extractor, src/infra/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.

Registration validates room geometry

/register validates every room’s position, dimensions, and capacity before persisting, the same validation every other geometry-writing route (createRoom, updateRoom, replaceRooms) applies — a malformed room is rejected with a 400 Validation Error, never silently stored.


Name Normalisation

Free functions in src/models.rs.


Downstream Synchronisation

A building’s structure is mirrored to two services. The two paths have different failure semantics, which matters operationally.

sequenceDiagram
    autonumber
    participant C as Client
    participant T as twin-service
    participant DB as twin-db
    participant S as sensor-service
    participant K as contracts-service
    C->>T: POST /register { name, rooms, domains }
    Note over T: normalise names, validate geometry, assign UUID
    T->>DB: insert
    T->>S: PUT /thresholds/buildings/:id (clone)
    Note over T,S: fails the registration on a non-2xx response
    T->>K: POST /preferences/init/:id
    Note over T,K: best-effort — errors logged, swallowed
    T-->>C: 201 building

Known gap: synchronous coupling

The sensor clone is an inline, synchronous REST call inside the register/update/room handlers. The roadmap is to replace it with a published TWIN_UPDATED event so sensor-service can update its copy asynchronously, decoupling the two services.


Error Handling

AppError (src/models.rs) implements IntoResponse, 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/infra/authz.rs embeds the shared backend/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/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).

twin-service 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-service’s can_override_model.