/

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

Socket Service Architecture

Bounded context: real-time transport (generic) · Stack: Rust / Axum / socketioxide · Code-level walkthrough: Socket Service

The socket is the bridge between the broker and the browser. It carries per-building telemetry and alerts to connected clients over WebSockets. It is classified as a generic subdomain (see Telemetry Distribution: pure transport, with no domain model and no database.


Architectural Style

Deliberately the simplest service in the system: no persistence and no domain model. No ports-and-adapters layering, because there is no domain logic to isolate. Instead the split is the directory structure itself — src/core/ and src/shell/ — so the architecture is visible in the file tree and mechanically checkable.

The connect middleware decodes the mesh-injected x-gateway-claims header on the WebSocket handshake (Istio’s RequestAuthentication already verified it once, upstream — the same trust model every other migrated service uses) and stores the resulting Identity in the socket’s extensions, server-side.

subscribe_building authorises per building, not per session: shell/twin.rs asks digital-twin for GET /domain/{buildingId}, forwarding the caller’s own claims header, and core::auth::may_read_building requires a shared domain before the socket joins the room. The emit path is untouched — room membership is the authorisation, so relaying stays a single io.to(room) with no per-message check.

Authorisation is point-in-time, evaluated at subscribe. A socket keeps its rooms until it is dropped, so that window is bounded by the socket lifetime below rather than by network stability.

Socket lifetime

A socket is disconnected once it has been open longer than SOCKET_MAX_LIFETIME_SECS (default 15 min, matching claims-gateway’s TokenTTL). A sweep task in shell/server.rs walks io.sockets() every core::session::sweep_interval (a tenth of the lifetime, capped at 30s) and drops whatever has aged out, counting socket_sessions_expired_total.

Reconnection re-runs the whole handshake, so the claims header is rebuilt from the cookie the browser sends — that is the re-validation. It needs no user interaction, but it does need the client to ask for it.

A forced disconnect is the one case socket.io does not retry

SocketRef::disconnect() sends a namespace DISCONNECT packet; socket.io-client reports that as io server disconnect, tears the engine.io transport down, and deliberately does not reconnect. services/socket.ts therefore reconnects explicitly on that reason, through the same delayed hydrate-then-connect path connect_error uses — so an expired session stays down instead of hammering the edge. Because the transport really is gone, the reconnect is a fresh HTTP handshake with a fresh cookie, not a namespace re-CONNECT replaying the original headers.

Room membership does not survive the reconnect. Domain rooms are rejoined server-side from the new claims; building rooms are client state, so useSensorData’s connect handler re-emits subscribe_building and refetches to close the gap in the series.

core::session::lifetime_for subtracts a per-socket jitter of up to an eighth of the lifetime, derived by hashing the socket id. Without it every client connected by the same deploy would expire in lockstep and re-handshake in one burst, forever.

A fixed lifetime bounds the socket, not the gap past token expiry

Total socket life is capped at the configured lifetime (plus at most one sweep interval), but a socket opened just before its token dies can still outlive that token by nearly the full window. Closing that exactly needs an exp in the Stable Claims Contract, which {sub, accountName, sid, memberships} does not carry. Accepted: with the frontend keep-alive renewing every 10 min, an active client’s token is continuously fresh, so the residual case is a client that stops renewing — and that one is bounded by the lifetime.

graph LR
    subgraph "src/shell — Imperative Shell"
        SRV["server.rs\ncomposition root + Redis loop"]
        HND["handlers.rs\nconnect · subscribe · emit"]
        TWIN["twin.rs\nbuilding → domains, cached"]
        SWEEP["server.rs\nlifetime sweep"]
    end
    subgraph "src/core — Functional Core"
        AUTH["auth.rs\nauthenticate_claims_header"]
        REL["relay.rs\ntelemetry_delivery · notification_delivery"]
        ROOMS["rooms.rs\nroom_for_building · room_for_domain\nbuilding_id_from_channel"]
    end
    BR1[(broker: notifications)] --> SRV
    BR2[(broker: telemetry:filtered:*)] --> SRV
    SRV --> REL
    SRV -->|"io.ns('/', on_connect.with(authenticate))"| HND
    HND --> AUTH
    HND --> TWIN
    SWEEP --> SESS["session.rs\nlifetime_for · sweep_interval"]
    TWIN -->|"GET /domain/{id}"| TS[digital-twin]
    HND --> ROOMS
    REL --> ROOMS
    SRV -->|room-scoped| CL[Connected clients]
    SRV -->|room-scoped / broadcast| CL

Fitness functions

The core/shell split is test-enforced, not a convention — tests/architecture.rs walks src/core/ and src/shell/ and fails the build on:

RuleWhy it matters
Every Rust source file under src/ lives in src/core/ or src/shell/ (only lib.rs and main.rs are exempt).Without this, a new file added at src/ level is silently exempt from every rule below. This is the rule that keeps the others honest as the service grows.
Nothing under src/core/ imports an I/O crate — no axum, redis, socketioxide, tokio, tower_http, prometheus.The moment a socket type reaches the core, its logic stops being testable without infrastructure.
Nothing under src/core/ names crate::shell.Dependencies point inward. The shell calls the core, never the reverse.
No async fn or await under src/core/.A pure function has nothing to await; async there is proof that I/O leaked in.
The literals "building: and "domain: appear only in core/rooms.rs.Room names are a wire contract with the browser. One source, or they drift.
The literal x-gateway-claims appears nowhere under src/.The header name is the trust boundary, owned by claims-schema and re-exported by core::auth as CLAIMS_HEADER.
main.rs reaches only shell::server.It binds a port and installs signal handlers; wiring is server.rs’s job.

Both directory scans assert they found files at all, so a rename can’t quietly make every rule vacuously true.

Each rule was verified by injecting a violation and confirming the corresponding test fails — a fitness function that has never gone red is an assertion about nothing.

Components & Connectors diagram

Socket Service C&C diagram

Key Architectural Decisions


Integration

For the channel-to-event mapping, the client subscription protocol, and scaling notes, see the Socket Service internals page.