/

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

Chat Service

The chat is the conversation layer in front of the agent. The Agent Service is intentionally stateless — each POST /ask is an independent call that takes an optional history array but stores nothing. The chat is what turns that into a real chat product: it persists conversations, owns them per user, and feeds the recent history back into each agent call.

It is a Rust / Axum service over MongoDB, laid out as Ports & Adapters like the other Rust backend services. Keeping conversation storage out of the Python agent is deliberate: the agent stays a pure question-answering engine, while session persistence — a different concern with a different database and scaling profile — lives in its own service.


Source Layout

PathResponsibility
src/lib.rsRoute table for conversation CRUD and message sending, plus health and metrics.
src/domain/conversation.rsThe conversation model, validation, the history window, and auto-titling. Pure.
src/domain/identity.rsThe validated caller identity, and the re-export of the claims payload from claims-schema (which owns the x-gateway-claims header name).
src/service/conversations.rsUse cases: ownership checks, the message cap, and the streaming exchange.
src/service/ports.rsConversationStore and AgentClient — the two things outside the service.
src/adapters/driving/http_api/HTTP: claims extraction, controllers, SSE frame encoding, error rendering.
src/adapters/driven/persistence/conversations.rsMongoDB store. The only place that knows what an ObjectId is.
src/adapters/driven/agent.rsCalls agent /ask and parses its SSE stream into domain events.
graph TD
    HTTP["adapters/driving/http_api\nclaims · controllers · SSE frames"] --> UC["service/conversations.rs\nownership, cap, exchange"]
    UC --> DOM["domain/\nvalidation, history, titling"]
    UC --> PORTS["service/ports.rs\nConversationStore · AgentClient"]
    PORTS -.-> MONGO["adapters/driven/persistence"]
    PORTS -.-> AG["adapters/driven/agent.rs"]
    MONGO --> DB[("chat-db")]
    AG -->|"x-gateway-claims forwarded"| AGENT["agent /ask"]

Data Model

A conversation is a single MongoDB document with its messages embedded rather than stored in a separate collection. A chat is loaded and saved as a whole, and message counts are bounded, so embedding avoids a join and makes each individual MongoDB save atomic.

Sending a message is not atomic end-to-end: it loads the conversation, waits for an external agent request, then appends the exchange. Concurrent sends can therefore use stale history. The append itself is a single $push + $set update rather than a read-modify-write, so two concurrent sends cannot lose each other’s messages, but the client still serialises sends from one widget and stronger server-side concurrency control remains future work.

pub struct Conversation {
    pub id: String,           // bare hex, never {"$oid": ...}
    pub user_id: String,      // owner; indexed { userId: 1 }
    pub title: String,        // defaults to "New chat"; auto-set from the first question
    pub messages: Vec<ChatMessage>, // embedded, capped at MAX_MESSAGES
    pub created_at: String,
    pub updated_at: String,   // the conversation list sorts on this
}

pub struct ChatMessage {
    pub id: String,           // every embedded message carries its own id
    pub role: Role,           // user | assistant
    pub content: String,      // 1..MAX_MESSAGE_LENGTH chars
    pub citations: Option<Vec<Citation>>, // grounding sources, from the agent's answer
    pub created_at: String,
}

Citation keeps snake_case field names (chunk_id, document_id, source, section_path): they are agent’s Python payload, stored and returned unchanged.

Citations are persisted on the assistant message so that reopening an old conversation shows the same grounded sources the agent originally returned, without re-querying.

The REST shapes are pinned in schemas/fixtures/chat-conversation.json, with the written rules in schemas/json/chat-conversation.schema.json (checked by claims-schema):


HTTP API

Conversation routes require authentication. GET /health and GET /metrics are public operational endpoints. The service is exposed through Caddy at http://localhost/chat (and the same /chat ingress prefix in Kubernetes).

Method · PathDescription
POST /conversationsCreate an empty conversation for the caller. Optional title.
GET /conversationsList the caller’s conversations, newest first, without message bodies.
GET /conversations/:idFetch one owned conversation with its full message history.
PATCH /conversations/:idRename an owned conversation.
DELETE /conversations/:idDelete an owned conversation.
POST /conversations/:id/messagesSend a user message; streams the assistant reply as SSE (see below).
GET /health/, GET /metrics/Liveness and Prometheus metrics.

Every conversation route resolves the document by { _id, userId }, so one user can never read or mutate another user’s chat — an unmatched id returns 404 rather than 403, to avoid revealing that the conversation exists.


Sending A Message

POST /conversations/:id/messages is where the chat orchestrates the agent. It loads the conversation, takes the last HISTORY_MAX_MESSAGES turns as history, opens a stream to the agent, relays each token to the browser as it arrives, and persists the exchange once the stream completes.

The response is Server-Sent Events, not JSON:

FrameMeaning
{"type":"token","text":…}One chunk of the answer. Zero or more, in order.
{"type":"done","message":…}Terminal. Carries the saved assistant message, with its id and citations.
{"type":"error","error":…,"message":…}Terminal. A failure that happened after the response had already begun.
sequenceDiagram
    autonumber
    participant C as Client (ChatWidget)
    participant CH as chat
    participant DB as chat-db
    participant A as agent
    C->>CH: POST /conversations/:id/messages { content }
    CH->>DB: load conversation by { _id, userId }
    Note over CH: validate, check the cap, take last HISTORY_MAX_MESSAGES turns
    CH->>A: POST /ask { question, history, stream:true } (+ x-gateway-claims header)
    A-->>CH: SSE: token …
    CH-->>C: SSE: token … (relayed, and accumulated in memory)
    A-->>CH: SSE: done { citations }
    CH->>DB: append user + assistant messages, bump updatedAt
    CH-->>C: SSE: done { message }

Design points worth calling out:

The first user message also seeds the conversation title (the prior default is "New chat"), so a freshly created chat gets a meaningful name without a separate request.


Configuration

VariableDefaultPurpose
AGENT_URLhttp://agent:3000Where to reach the agent’s /ask endpoint.
MONGO_URIConnection string for chat-db.
HISTORY_MAX_MESSAGES10Number of recent turns sent to the agent as context. Read once at startup.

MAX_MESSAGES (100), MAX_MESSAGE_LENGTH (8000), and MAX_TITLE_LENGTH (120) are fixed in-code limits that bound document growth and reject oversized input.


Authentication

A GatewayClaims extractor decodes the mesh-injected x-gateway-claims header — Istio’s RequestAuthentication already verified the gateway JWT once, at the ingress — and takes the sub claim as the owning userId. There is no signature verification here at all; chat trusts the header rather than re-checking a JWT itself. The raw header is kept and forwarded verbatim to the agent. A failure to reach the agent surfaces as a 502 Bad Gateway, distinguishing an upstream agent problem from a client error.


Client

In the frontend, the assistant is the floating ChatWidget.vue, driven by the useChatSessions composable, which calls these /chat/* routes. It appends the question and an empty assistant message optimistically, grows that message as token frames arrive, and replaces it with the saved one on done; a failure removes both again, so a broken stream never leaves a partial answer on screen. For what the assistant can and cannot answer from a user’s perspective.