/

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

Communication & Data Flow

CrowdVision uses three communication styles, chosen deliberately per relationship:

This page maps all three, then walks five end-to-end flows.


Identity & Trust: How a Caller’s Identity Travels

There is no central session store and no per-request call back to an identity authority. Two distinct mechanisms carry trust, and which one applies depends on whether a human is behind the request:

MechanismUsed whenHow it works
x-gateway-claims header, forwarded verbatimThe call happens on behalf of an end user (a service calling another service mid-request, to fulfil something a user asked for).claims-gateway verifies the user once, mints a signed token; the mesh (Istio RequestAuthentication in k8s, Caddy forward_auth in compose) verifies it once more at the edge and injects the verified claims as one header. Every service downstream decodes that header instead of re-verifying a JWT, and if it calls another service, it forwards the same header unchanged — mTLS already authenticates the network hop itself, so the header only needs to carry who the original caller was, not prove the hop is legitimate.
HMAC X-Signature, shared secretThe call is control-plane, backend-only, with no end user in the loop (organization provisioning).Caller computes HMAC-SHA256(sharedSecret, requestBody), sends it as X-Signature; callee recomputes and compares in constant time. There is no user identity to forward — this authenticates the calling service itself. Both halves are one implementation: authcontracts.Sign and authcontracts.RequireSignature in Auth Contracts, asserted against golden vectors that telemetry’s independent Rust verifier asserts too.

Both patterns exist for the same reason: nobody re-implements JWT verification or invents a second signing scheme per pair of services. Every mesh-claims consumer and every HMAC consumer reuses one shared convention, respectively — see Identity & Tenancy Architecture and Service Mesh Architecture for the full verification mechanics.

A third category — no authentication at all — is used only where forwarding an identity would be meaningless or actively wrong: device-facing ingestion (a sensor has no user token to present) and a small number of internal bootstrap calls that write nothing sensitive (flagged individually in the table below).


Synchronous Communication

Browser ↔ Gateway

All external traffic enters through the single gateway (Caddy in compose, Istio Gateway API in k8s), which routes by URL prefix. See the routing table in the Overview — not repeated here.

Service-to-Service Calls

Caller → CalleeMethod + PathAuthPurpose
digital-twin → telemetryPUT /thresholds/buildings/{id}x-gateway-claims forwardedOn a later building/room edit, push an updated threshold baseline. Not used for registration — that goes over Kafka instead (see Asynchronous Communication below).
digital-twin → telemetryPATCH /thresholds/peopleCount/buildings/{id}/rooms/{id}x-gateway-claims forwardedSeed a new room’s people-count threshold.
digital-twin → dashboardPOST /preferences/init/{buildingId}NoneBest-effort seed of a new building’s default display preferences; failure is logged, never surfaced to the caller.
notification → digital-twinGET /domain/{buildingName}x-gateway-claims forwarded if presentResolve which domain(s) a building belongs to, to select alert recipients.
dashboard → telemetry / digital-twinGET /contracts (target from *_METRICS_URL env, not hardcoded)NoneDiscover each service’s self-describing metric catalog.
chat → agentPOST /askx-gateway-claims forwardedForward a user’s chat message to the assistant, under the user’s own identity.
agent → digital-twinGET /buildings/{domain}x-gateway-claims forwardedTool call: read live building data to answer a question.
agent → telemetryGET /{metric}/latest, /{metric}/entireBuilding, /{metric}/dashboard, /sensors/buildings/{id}[/rooms/{id}]x-gateway-claims forwardedTool calls: read live sensor readings to answer a question.
claims-gateway → tenancyPOST /internal/provisionHMAC X-SignatureOnce per login (never per request): first-login-provision the caller’s membership from their verified IdP claim.
claims-gateway → tenancyGET /internal/membershipsHMAC X-SignatureResolve the caller’s existing memberships before minting the internal token.
provisioner → registryGET /internal/organizations/pendingHMAC X-SignatureReconcile loop: list organizations awaiting provisioning.
provisioner → registryPOST /internal/organizations/{id}/statusHMAC X-SignatureReport an organization back to ready/failed. Those two are the only accepted values — anything else is rejected with 400 and nothing is written, so a typo cannot silently mark a tenant live.
provisioner → tenancyPOST /internal/domainsHMAC X-SignatureCreate the tenancy domain for a newly-approved organization (idempotent — tolerates 409 as success).

These calls are intentionally few. The design keeps the request path short and avoids synchronous chains on the hot path; anything high-frequency goes over the broker instead.

One outbound call carries no auth — by design, not by omission

dashboard’s own outbound discovery calls send no header of any kind: they only read a metric catalog from digital-twin and telemetry’s public /contracts endpoints, with no sensitive side effect for an unauthenticated caller to exploit. Every call that reads or writes something a specific user’s authorization should gate carries x-gateway-claims — including the digital-twin → dashboard preferences-init call, which forwards the building creator’s claims verbatim, the same as twin’s other outbound calls. dashboard now requires that header on all of its own inbound user-facing routes (dashboard schema and preferences), so those are no longer reachable anonymously.

registry and tenancy never call out

Both are pure receivers of the calls above — neither has an outbound HTTP client for any peer service. Only claims-gateway talks to Keycloak (Admin API for user management, OIDC endpoints for token exchange/verification); no other service holds Keycloak credentials or configuration.

Device Ingestion

POST /ingest on telemetry — no user token (a sensor gateway has none to present), so it is exempt from the gateway’s auth gate and carries its own credential instead: X-Signature, an HMAC-SHA256 over the raw body keyed by TELEMETRY_INGEST_SECRET — the same X-Signature scheme as the control-plane hops above, but a separate key, since this one leaves the mesh and lives on customer premises. Unsigned or mis-signed requests are 401 before any work. The endpoint validates, persists and evaluates thresholds before answering 202 Accepted, so a 202 means the reading is durable and queryable.


Asynchronous Communication

Redis is the publish/subscribe backbone for everything high-frequency and fire-and-forget; Kafka carries the two flows that must survive a restart. Three channels, one stream and one topic carry the event flow:

Channel / StreamPublisherSubscriberCarries
telemetry:raw (pub/sub)telemetrydashboardEvery accepted reading, as a normalized event.
telemetry:filtered:{buildingId} (pub/sub)dashboardsocketEvery reading for that building, routed by the event’s own buildingId.
alerts (Kafka topic)telemetrynotificationEvery threshold breach, keyed buildingId:roomId. Durable, unlike the pub/sub channels: a breach produced while notification is down is processed on its return. The consumer filters type == "temperature".
notifications (pub/sub)notificationsocketThe resolved, recipient-scoped alert event (carries domainName), ready for in-app delivery.
account.deleted (stream, consumer group)(no publisher found in this codebase — see the gap note below)tenancy{accountId} — triggers membership cleanup for a deleted Keycloak account.

Publishers never know who (if anyone) is listening, and the broker absorbs bursts, so a slow or restarting consumer cannot back-pressure a sensor.

account.deleted has a consumer but no publisher

tenancy’s consumer (internal/events/account_deleted.go) is fully implemented — consumer group, at-least-once redelivery on processing failure, idempotent reap — but no component in this codebase ever calls XAdd on this stream. It is presumably meant to come from a Keycloak event listener that does not yet exist. As things stand, deleting a Keycloak account does not currently clean up its memberships. See claude/issues/issues.md for tracking.

Kafka

Unlike Redis, a Kafka topic is a durable log: a consumer that’s down when a message is produced still sees it once it comes back, which is why this is the transport for the one flow where digital-twin and telemetry must eventually agree even if either one restarts mid-registration — not for anything high-frequency.

TopicProducerConsumerCarries
building-registration-requesteddigital-twintelemetrytwin_schema::RegistrationRequest{ buildingId, name, rooms: [{ id, name }], maxTemperature? }, keyed by buildingId, published once the twin’s own write has succeeded. A room with no id is dropped on parse rather than failing the building.
building-registration-completedtelemetrydigital-twintwin_schema::RegistrationCompleted{ buildingId, status: "ready" | "failed", error? }, keyed by buildingId — resolves the caller’s tracking handle.

Both payloads and both topic names are defined once, in schemas/twin-schema, and each service re-exports them from its own adapters/topics.rs — see Twin Schema.

Consumer groups are named after what they consume, not after the service consuming them, so a group name stays right even if the service is renamed.

GroupConsumerTopic
alertsnotificationalerts
building-registrationstelemetrybuilding-registration-requested
building-registrations-completeddigital-twinbuilding-registration-completed

Two services must never share a group id

A consumer group is a work-sharing unit: Kafka assigns each partition to exactly one member. These topics have a single partition, so putting digital-twin and telemetry in one group would hand that partition to one of them and leave the other silent — and which one wins depends on rebalance order, so it can pass in dev and fail in production. Different services that each need every message need different group ids. The same group id is for replicas of one service, which is why two notification pods would correctly share alerts and split the load.

Renaming a consumer group is a replay, not a reset

A group with no committed offsets combined with auto.offset.reset=earliest reads the topic from the beginning, so the first deploy after a rename re-consumes every retained record. Harmless for the registration groups — every write either side makes is an upsert, so a replay converges. Not harmless for alerts: it re-delivers historical breaches as real Web Push notifications, collapsed per building:room by the 300s Redis cooldown but not otherwise suppressed. On a live cluster, let the topic age past its retention first, or seek the new group to latest once before starting the consumer.

Publishing is not blocking on either side: digital-twin’s worker returns as soon as the first message reaches the broker, and telemetry’s consumer processes it on its own schedule. Both topics carry no x-gateway-claims — this is internal transport between two services already inside the trust perimeter, not a client-facing hop. See Digital Twin Service for the full sequence and the idempotency argument (every write either side makes is an upsert, so redelivery converges rather than duplicating).


Browser-Facing Real-Time Channels

Socket.IO (socket)

DirectionEventDetail
Client → Serversubscribe_building / unsubscribe_building (buildingId)Join/leave the building:{id} room.
Server → ClienttelemetryRelayed from the Redis telemetry:filtered:{buildingId} channel to everyone in that building’s room.
Server → ClientnotificationRelayed from the Redis notifications channel to everyone in the resolved domain:{name} room; authenticated clients auto-join their domain room on connection.

Web Push (notification)

The client fetches the server’s public VAPID key (GET /notification/public-key), calls the browser’s pushManager.subscribe(), and registers the resulting subscription (POST /notification/subscribe). When an alert fires, notification sends a push payload ({title, message, icon}) via the web-push package directly to the browser’s push service — this path does not go through Redis or socket, and works even if the user has no tab open. A service worker (frontend/public/service-worker.js) handles the push event to show an OS-level notification and notificationclick to focus/open the app.


End-to-End Flows

Path 1: Live Telemetry to the Browser

sequenceDiagram
    participant Sensor as Sensor source
    participant SS as telemetry
    participant R as Redis
    participant CS as dashboard
    participant WS as socket
    participant UI as Vue client

    Sensor->>SS: POST /ingest (X-Signature HMAC)
    Note over SS: verify signature, validate, persist, normalize
    SS-->>Sensor: 202 Accepted
    SS->>R: PUBLISH telemetry:raw
    R-->>CS: telemetry:raw event
    Note over CS: read the event's own buildingId → route
    CS->>R: PUBLISH telemetry:filtered:{buildingId}
    R-->>WS: telemetry:filtered:{buildingId} event
    WS->>UI: emit "telemetry" to room building:{id}

dashboard is the routing stage: it republishes each reading to its own building’s channel (an O(1) lookup on the event’s buildingId), so a reading reaches exactly one building’s feed and never leaks into another’s. It does not filter by metric type — every metric a building produces is forwarded, and the dashboard’s column preferences only decide what the table displays, client-side.

Path 2: Threshold Alert to the User

sequenceDiagram
    participant SS as telemetry
    participant R as Redis
    participant K as Kafka
    participant NS as notification
    participant TW as digital-twin
    participant WS as socket
    participant Push as Browser push service
    participant U as User

    Note over SS: background: reading crosses a configured bound
    SS->>K: produce alerts (key buildingId:roomId)
    K-->>NS: alerts record
    Note over NS: throttle repeats (per-room cooldown)
    NS->>TW: GET /domain/{buildingName}
    TW-->>NS: domain name(s)
    Note over NS: select opted-in recipients
    NS->>R: PUBLISH notifications {domainName}
    NS->>Push: web-push send (VAPID)
    R-->>WS: notifications event
    WS->>U: emit "notification" to room domain:{name}
    Push-->>U: service worker "push" → OS notification

Unlike Path 1, this is a two-hop async chain, not one publish: telemetry never calls notification directly — the threshold trigger and the resolved, recipient-scoped notification are two distinct Redis events, with notification sitting in between doing the one synchronous call it needs (resolving domains from digital-twin) and fanning out over both delivery paths — in-app (broker + socket) and Web Push (direct, works with no tab open).

Path 3: Assistant Tool-Calling

sequenceDiagram
    participant U as Browser
    participant Chat as chat
    participant Agent as agent
    participant Twin as digital-twin
    participant Sensor as telemetry

    U->>Chat: POST /chat/... (x-gateway-claims via gateway)
    Chat->>Agent: POST /ask {question}, x-gateway-claims forwarded
    Note over Agent: LLM decides which tool(s) to call
    Agent->>Twin: GET /buildings/{domain}, x-gateway-claims forwarded
    Twin-->>Agent: building data
    Agent->>Sensor: GET /{metric}/latest, x-gateway-claims forwarded
    Sensor-->>Agent: reading
    Agent-->>Chat: answer
    Chat-->>U: chat response

Every tool call agent makes carries the original user’s x-gateway-claims header, forwarded across two hops (chat → agent → digital-twin/telemetry) — so digital-twin and telemetry apply the same Cedar authorization to an assistant-driven read as they would to a direct API call from that user. chat persists the conversation in its own MongoDB independently of this flow.

Path 4: Login & Identity Resolution

sequenceDiagram
    participant U as User
    participant CG as claims-gateway
    participant KC as Keycloak
    participant TS as tenancy

    U->>CG: POST /login (credentials or IdP redirect)
    CG->>KC: verify credentials / verify token
    KC-->>CG: verified identity + Organization claim
    CG->>TS: GET /internal/memberships (HMAC)
    alt no membership yet
        CG->>TS: POST /internal/provision (HMAC)
        TS-->>CG: membership created from the Organization claim
    end
    CG-->>U: signed internal token (StandardClaims)

This is the only point where tenancy is consulted for identity — once per login, never per request. Every subsequent request in a session is authorized locally, from the token/header, with zero further network hops for auth. See Identity & Tenancy Architecture for how the membership itself is derived, and Service Mesh Architecture for how the resulting token becomes the x-gateway-claims header at the mesh edge.

Path 5: Organization Signup & Provisioning

sequenceDiagram
    participant C as New customer
    participant RS as registry
    participant P as provisioner
    participant TS as tenancy

    C->>RS: POST /organizations (no auth — no credentials exist yet)
    RS-->>C: 201, status=provisioning
    loop every 15s
        P->>RS: GET /internal/organizations/pending (HMAC)
    end
    P->>TS: POST /internal/domains (HMAC, idempotent)
    TS-->>P: 201 or 409 (both = success)
    P->>RS: POST /internal/organizations/{id}/status (HMAC)

This is the control-plane path: no end user is authenticated mid-flow, so every internal hop uses the HMAC X-Signature scheme rather than a forwarded identity header. See Overview for what registry/provisioner/tenancy each own.


Why Four Styles

ConcernStyleReason
User actions (login, CRUD, assistant queries)Synchronous, identity forwardedThe caller needs an immediate, ordered result, and downstream services need to know who’s asking.
Organization provisioningSynchronous, HMAC-signedNo user identity exists to carry; the calling service authenticates itself instead. Failures are tolerated and retried on the next reconcile tick.
Telemetry fan-outAsynchronous, fire-and-forgetHigh volume, many consumers, and producers that must never block on a slow subscriber. No message needs to survive a restart — the next reading arrives in seconds regardless, and the dashboard re-fetches over REST on reconnect.
Threshold alertsAsynchronous, durableOne logical consumer, and a breach that resolves while notification is down would otherwise be lost with no record anywhere. Kafka keeps it until it is read. The producer still never waits on the broker: records are enqueued, not awaited.
Building registrationAsynchronous, durableExactly one outcome matters, and it has to be known eventually even if either service restarts mid-flight — a property Redis pub/sub doesn’t offer.