CrowdVision · source-available, not open source · © 2026 Nicolò Ghignatti
CrowdVision uses three communication styles, chosen deliberately per relationship:
This page maps all three, then walks five end-to-end flows.
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:
| Mechanism | Used when | How it works |
|---|---|---|
x-gateway-claims header, forwarded verbatim | The 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 secret | The 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).
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.
| Caller → Callee | Method + Path | Auth | Purpose |
|---|---|---|---|
| digital-twin → telemetry | PUT /thresholds/buildings/{id} | x-gateway-claims forwarded | On 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 → telemetry | PATCH /thresholds/peopleCount/buildings/{id}/rooms/{id} | x-gateway-claims forwarded | Seed a new room’s people-count threshold. |
| digital-twin → dashboard | POST /preferences/init/{buildingId} | None | Best-effort seed of a new building’s default display preferences; failure is logged, never surfaced to the caller. |
| notification → digital-twin | GET /domain/{buildingName} | x-gateway-claims forwarded if present | Resolve which domain(s) a building belongs to, to select alert recipients. |
| dashboard → telemetry / digital-twin | GET /contracts (target from *_METRICS_URL env, not hardcoded) | None | Discover each service’s self-describing metric catalog. |
| chat → agent | POST /ask | x-gateway-claims forwarded | Forward a user’s chat message to the assistant, under the user’s own identity. |
| agent → digital-twin | GET /buildings/{domain} | x-gateway-claims forwarded | Tool call: read live building data to answer a question. |
| agent → telemetry | GET /{metric}/latest, /{metric}/entireBuilding, /{metric}/dashboard, /sensors/buildings/{id}[/rooms/{id}] | x-gateway-claims forwarded | Tool calls: read live sensor readings to answer a question. |
| claims-gateway → tenancy | POST /internal/provision | HMAC X-Signature | Once per login (never per request): first-login-provision the caller’s membership from their verified IdP claim. |
| claims-gateway → tenancy | GET /internal/memberships | HMAC X-Signature | Resolve the caller’s existing memberships before minting the internal token. |
| provisioner → registry | GET /internal/organizations/pending | HMAC X-Signature | Reconcile loop: list organizations awaiting provisioning. |
| provisioner → registry | POST /internal/organizations/{id}/status | HMAC X-Signature | Report 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 → tenancy | POST /internal/domains | HMAC X-Signature | Create 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.
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.
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.
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.
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 / Stream | Publisher | Subscriber | Carries |
|---|---|---|---|
telemetry:raw (pub/sub) | telemetry | dashboard | Every accepted reading, as a normalized event. |
telemetry:filtered:{buildingId} (pub/sub) | dashboard | socket | Every reading for that building, routed by the event’s own buildingId. |
alerts (Kafka topic) | telemetry | notification | Every 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) | notification | socket | The 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.
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.
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.
| Topic | Producer | Consumer | Carries |
|---|---|---|---|
building-registration-requested | digital-twin | telemetry | twin_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-completed | telemetry | digital-twin | twin_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.
| Group | Consumer | Topic |
|---|---|---|
alerts | notification | alerts |
building-registrations | telemetry | building-registration-requested |
building-registrations-completed | digital-twin | building-registration-completed |
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.
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).
| Direction | Event | Detail |
|---|---|---|
| Client → Server | subscribe_building / unsubscribe_building (buildingId) | Join/leave the building:{id} room. |
| Server → Client | telemetry | Relayed from the Redis telemetry:filtered:{buildingId} channel to everyone in that building’s room. |
| Server → Client | notification | Relayed from the Redis notifications channel to everyone in the resolved domain:{name} room; authenticated clients auto-join their domain room on connection. |
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.
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.
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 notificationUnlike 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).
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 responseEvery 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.
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.
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.
| Concern | Style | Reason |
|---|---|---|
| User actions (login, CRUD, assistant queries) | Synchronous, identity forwarded | The caller needs an immediate, ordered result, and downstream services need to know who’s asking. |
| Organization provisioning | Synchronous, HMAC-signed | No user identity exists to carry; the calling service authenticates itself instead. Failures are tolerated and retried on the next reconcile tick. |
| Telemetry fan-out | Asynchronous, fire-and-forget | High 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 alerts | Asynchronous, durable | One 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 registration | Asynchronous, durable | Exactly 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. |