/

Developer Guide

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 with hmac.Equal (constant-time). There is no user identity to forward — this authenticates the calling service itself.

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
twin-service → sensor-servicePUT /thresholds/buildings/{id}x-gateway-claims forwardedOn building creation/update, push a threshold baseline for the building’s rooms.
twin-service → sensor-servicePATCH /thresholds/peopleCount/buildings/{id}/rooms/{id}x-gateway-claims forwardedSeed a new room’s people-count threshold.
twin-service → contracts-servicePOST /preferences/init/{buildingId}NoneBest-effort seed of a new building’s default display preferences; failure is logged, never surfaced to the caller.
notification-service → twin-serviceGET /domain/{buildingName}x-gateway-claims forwarded if presentResolve which domain(s) a building belongs to, to select alert recipients.
contracts-service → sensor-service / twin-serviceGET /contracts (target from *_METRICS_URL env, not hardcoded)NoneDiscover each service’s self-describing metric catalog.
chat-service → agent-servicePOST /askx-gateway-claims forwardedForward a user’s chat message to the assistant, under the user’s own identity.
agent-service → twin-serviceGET /buildings/{domain}x-gateway-claims forwardedTool call: read live building data to answer a question.
agent-service → sensor-serviceGET /{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 → tenancy-servicePOST /internal/provisionHMAC X-SignatureOnce per login (never per request): first-login-provision the caller’s membership from their verified IdP claim.
claims-gateway → tenancy-serviceGET /internal/membershipsHMAC X-SignatureResolve the caller’s existing memberships before minting the internal token.
provisioner → registry-serviceGET /internal/organizations/pendingHMAC X-SignatureReconcile loop: list organizations awaiting provisioning.
provisioner → registry-servicePOST /internal/organizations/{id}/statusHMAC X-SignatureReport an organization back to ready/failed.
provisioner → tenancy-servicePOST /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

contracts-service’s own outbound discovery calls send no header of any kind: they only read a metric catalog from twin-service and sensor-service’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 twin-service → contracts-service preferences-init call, which forwards the building creator’s claims verbatim, the same as twin’s other outbound calls. contracts-service 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-service and tenancy-service 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 sensor-service — no authentication (a physical sensor or simulator has no user token to present), and exempt from the gateway’s auth gate for the same reason. The endpoint responds 202 Accepted immediately and validates/persists/evaluates thresholds in the background (setImmediate), so a slow downstream step never makes an IoT device wait or retry.


Asynchronous Communication

Redis is the publish/subscribe backbone for everything high-frequency. Four channels and one stream carry the event flow:

Channel / StreamPublisherSubscriberCarries
telemetry:raw (pub/sub)sensor-servicecontracts-serviceEvery accepted reading, as a normalized event.
telemetry:filtered:{buildingId} (pub/sub)contracts-servicesocket-serviceEvery reading for that building, routed by the event’s own buildingId.
alerts:temperature (pub/sub)sensor-servicenotification-serviceA temperature reading that crossed a configured threshold — the trigger for the alert path, not yet a user-facing notification.
notifications (pub/sub)notification-servicesocket-serviceThe 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-service{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-service’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.

sensor:latest:temperature:{buildingId} is a cache, not a message

sensor-service also uses Redis as a 10-second TTL cache for each room’s latest temperature reading (GET/SETEX), independent of the pub/sub channels above. It exists purely to serve fast reads for agent-service’s dashboard tool calls without re-querying the primary datastore, not to communicate between services.


Browser-Facing Real-Time Channels

Socket.IO (socket-service)

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-service)

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-service 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-service, 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 sensor-service
    participant R as Redis
    participant CS as contracts-service
    participant WS as socket-service
    participant UI as Vue client

    Sensor->>SS: POST /ingest (no auth)
    SS-->>Sensor: 202 Accepted
    Note over SS: background: validate, persist, normalize
    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}

contracts-service 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 sensor-service
    participant R as Redis
    participant NS as notification-service
    participant TW as twin-service
    participant WS as socket-service
    participant Push as Browser push service
    participant U as User

    Note over SS: background: reading crosses a configured bound
    SS->>R: PUBLISH alerts:temperature
    R-->>NS: alerts:temperature event
    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: sensor-service never calls notification-service directly — the threshold trigger and the resolved, recipient-scoped notification are two distinct Redis events, with notification-service sitting in between doing the one synchronous call it needs (resolving domains from twin-service) 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-service
    participant Agent as agent-service
    participant Twin as twin-service
    participant Sensor as sensor-service

    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-service makes carries the original user’s x-gateway-claims header, forwarded across two hops (chat-service → agent-service → twin-service/sensor-service) — so twin-service and sensor-service apply the same Cedar authorization to an assistant-driven read as they would to a direct API call from that user. chat-service 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-service

    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-service 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-service
    participant P as provisioner
    participant TS as tenancy-service

    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-service/provisioner/tenancy-service each own.


Why Three 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 & alertsAsynchronousHigh volume, many consumers, and producers that must never block on a slow subscriber.