Skip to content

Chat

Chat is an organization-scoped conversation attached to an arbitrary resource. It is the surface where reviewers and operators talk about a resource — and the channel through which the platform's agents converse with users: a bot message is simply a message with no author.


Properties

Field Type Description
organization_id Organization relation Owning organization; every access check is org-scoped
mode enum: RESOURCE, DIRECT How access is authorized (see below)
context_type enum: SHOOTING, SET_DESIGN, ORG_ASSISTANT Which kind of resource the chat is attached to
context_id UUID The attached resource's id — polymorphic, no foreign key
title string, optional Display title

One live chat exists per resource: a partial unique index on (context_type, context_id) scoped to deleted_at IS NULL, so a soft-deleted chat never blocks re-creating one.

Context types

Context type context_id points at Backing entity
SHOOTING a shooting row Shooting
SET_DESIGN a set-design row SetDesign — org-scoped only; personal designs (null organization) are rejected
ORG_ASSISTANT the organization itself (context_id == organization_id) none — one global assistant chat per organization

ORG_ASSISTANT is the deliberate special case: its "resource" is the organization, which is what gives each org a single, always-available assistant conversation.

Modes

  • RESOURCE (default) — access follows the resource: any member of the chat's organization can read and post. The participant roster is unused.
  • DIRECT — a private conversation authorized by an explicit ChatParticipant roster. The model and endpoints exist; no product surface uses it yet.

Data model

erDiagram
    Chat ||--o{ ChatMessage : contains
    Chat ||--o{ ChatParticipant : "roster (DIRECT only)"
    ChatMessage ||--o{ MediaResourceAttachment : attachments
    ChatMessage }o--o| ChatMessage : reply_to

    Chat {
        uuid id PK
        uuid organization_id FK
        enum mode "RESOURCE | DIRECT"
        enum context_type "SHOOTING | SET_DESIGN | ORG_ASSISTANT"
        uuid context_id "polymorphic, no FK"
        string title
        datetime deleted_at
    }

    ChatMessage {
        uuid id PK
        uuid chat_id FK
        uuid owner_id FK "NULL = system/bot"
        string text "max 8192"
        bool is_private "inverted naming, see below"
        uuid reply_to_id FK "ON DELETE SET NULL"
        json options "pressable options payload"
        datetime deleted_at
    }

    ChatParticipant {
        uuid id PK
        uuid chat_id FK
        uuid user_id FK
    }

Messages participate in the MediaResource system through the attachments slot (MediaEntityType.CHAT_MESSAGE), so an image-only message is valid.


Visibility and roles

is_private reads backwards: is_private = true means shared / reviewer-visible; false is an operator-internal note. Everything else follows from that.

Rule Behavior
Reviewers see only the private subset Message listing, batch counts, the action-needed scan and the WebSocket fan-out all filter on is_private for a REVIEWER viewer
A reviewer cannot write an internal note Posting coerces is_private = true for any reviewer author, whatever the request says
Authors are masked for reviewers A reviewer never learns who wrote someone else's message — owner / owner_id are nulled per viewer (REST) and per recipient (WebSocket). The author always sees their own identity
Typing is anonymized A typing signal delivered to a reviewer carries no user_id; the UI shows a generic "someone is writing…"
A reviewer comment reopens the resource Posting as a reviewer reopens the backing resource (a published set design returns to draft) in the same transaction as the message

Message rules

Rule Value
Text Max 8192 characters; text or at least one attachment is required
Reply-to Flat, one level; the parent must be a live message in the same chat. ON DELETE SET NULL — a reply outlives its parent
Reply preview Caller-aware: full text when the viewer may see the parent, is_restricted when not, is_unavailable when the parent is soft-deleted
Delete window Own message, within 5 minutes of posting
Pagination Keyset over (created_at, id); the returned page is ordered oldest→newest with an opaque next_cursor for older history

Pressable options

A bot message can offer pressable items instead of asking for free text — for example "which two subjects should we use?".

Storage is a minimal, normalized JSON payload on the message (direction, kind, select, resolved, selected_values, values) holding opaque ids only — no labels, no URLs. The public shape is hydrated at read time.

Field Meaning
direction offer — a bot message proposing items; response — an internal carrier on a user message recording the choice (never exposed publicly)
kind option.subject today; new kinds slot in as additional payload types
select single or multi, enforced server-side
resolved Locked once answered — a second answer is rejected with 409

A user answers either by pressing (a dedicated respond endpoint validates, locks the offer and emits a live update) or by typing the names (the agent's tool applies the selection and the lock is written inside the same turn transaction). Both paths are idempotent.


API surface

All endpoints live under /chats; write and list endpoints are organization-gated.

Method + path Purpose
POST /contexts/{context_type}/{context_id} Get-or-create the chat for a resource (201 created / 200 existing). POST because it may create a row
GET / · GET /{chat_id} List / read chats
GET /contexts/{context_type}/messages/count Batch message counts for up to 200 contexts
GET /contexts/action-needed Chats awaiting the caller's reply (see below)
POST /{chat_id}/messages · GET /{chat_id}/messages · DELETE /{chat_id}/messages/{message_id} Post, list (keyset), delete
POST /{chat_id}/messages/{message_id}/options/respond Press an options block (reviewer-only)
POST /{chat_id}/participants · DELETE /{chat_id}/participants/{user_id} DIRECT roster management

Action needed

A context "needs action" when the last message the caller can see is at least 2 minutes old — there is no maximum age — and was written by a different role than the caller, so a thread where the caller's own side spoke last is skipped. A bot/system message counts only for REVIEWER callers. Contexts whose backing resource is already completed (a published set design) are dropped.

The response carries a grand total (the global badge) plus a per-context-type breakdown with the individual resource ids, so a page can mark each card that needs a reply.


Real-time

Chat rides the platform WebSocket connection; events fan out through Redis pub/sub.

Direction Frame / event Notes
client → server chat.subscribe Authorizes the connection for a chat, then replays the recent backlog to it
client → server chat.unsubscribe, chat.typing Typing is ignored unless subscribed
server → client chat.message_created, chat.message_updated Carry the full message; visibility-filtered per recipient — an operator-internal message never reaches a reviewer connection, and authors are masked per recipient
server → client chat.message_deleted Ids only, deliberately not visibility-filtered — a tombstone leaks nothing
server → client chat.typing Ephemeral, never stored. is_bot = true (with no user id) is the agent's "Assistant is writing…" signal
server → client chat.subscribed, chat.subscribe_error Ack / rejection (not_found, forbidden, bad_request); the connection stays open

Lifecycle hooks

The chat service notifies a neutral hook registry on two events — chat created (synchronous) and user message posted (asynchronous, after commit and fan-out). Dispatch is best-effort: a failing hook is logged and never fails the chat operation.

This registry is what lets the agent subsystem provision and drive a conversation without the chat code ever importing it — a one-way dependency with no import cycle. See Agent Conversation Flow.