# stapel-chat 0.2.0

Conversations and messaging: direct (1:1, idempotent by participant pair), group and support threads on one model; text/system messages with a monotonic per-conversation seq, replies and opaque CDN attachment keys; per-participant read markers and unread counts; anchor-paginated history (canonical anchor=seq) and conversation lists; a support layer (unassigned queue, first-come assignment, open/pending/resolved with reopen); and an optional Channels consumer for realtime delivery with seq-replay resume.

Contract: axes 3 · surface 11 · extension points 4 · operations 10 · error codes 54.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- ATTACHMENTS [bool, default true] — Allow message attachments
  When on, a message may carry a list of opaque attachment keys (the files live in the host's CDN/storage; the module stores only the keys). When off, any message with an attachment is rejected. Text is always allowed.
- CHAT_KINDS [list, default ["direct", "group", "support"]] — Which conversation types are offered
  The enabled subset of direct (1:1), group and support threads. Drop "support" to run a pure peer-to-peer chat with no operator queue or assignment; drop "group" to allow only 1:1 direct threads. Creating (or, for support, operating on) a kind that is not enabled is refused. Values: any non-empty subset of direct, group, support.
- MAX_BODY_LENGTH [enum, default 4000] — Maximum message length
  Hard cap, in characters, on a single text message body. A longer body is rejected before it is stored. Raise it for long-form support replies, lower it for terse chat.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- assign_operator — stapel_chat.services.assign_operator
  First-come claim of a support thread: re-reads the conversation under select_for_update, refuses a second operator with AlreadyAssigned, stays idempotent for the same one, adds the operator participant, emits chat.support.assigned and posts the system line. Two operators pressing "take" in the same second is the race this exists for — setting assigned_operator directly loses it and silently steals the thread.
- mark_read — stapel_chat.services.mark_read
  instead of: stapel_chat.models.ConversationParticipant.objects.update
  Advance a participant's read marker to upto_seq and never backwards — the update is filtered on last_read_seq__lt, and the return value says whether it actually moved. Assigning last_read_seq yourself lets an out-of-order client rewind the marker and resurrect already-read messages as unread.
- reopen_support — stapel_chat.services.reopen_support
  Reopen a resolved thread back to open with the chat.support.reopened system line. Note it re-enters support_queue() only while no operator is assigned — a reopened thread stays with its previous operator by design.
- resolve_support — stapel_chat.services.resolve_support
  Close a support thread — status=resolved plus the chat.support.resolved system line. Call the wrapper so "resolved" reads the same way in every deployment's transcript and no thread is closed without a marker in its history.
- set_support_status — stapel_chat.services.set_support_status
  Move a support thread between open/pending/resolved and optionally post the system line marking the transition; refuses a direct/group conversation with NotSupport. This is the one to call for pending, which has no dedicated wrapper.
### factory
- create_direct — stapel_chat.services.create_direct
  instead of: stapel_chat.models.Conversation.objects.get_or_create
  Get-or-create the 1:1 thread between two users — idempotent by an order-independent direct_key over (scope_key, both ids), with the create race decided by the partial unique constraint (the loser returns the winner's row). Reach for this instead of your own get_or_create over a participant pair: a hand-rolled one has nothing unique to key on and quietly hands the two users two different threads.
- create_group — stapel_chat.services.create_group
  instead of: stapel_chat.models.Conversation.objects.create
  Create a group thread and seed its participant rows in the same call (owner first, ids deduped, conflicts ignored). Group threads are deliberately never deduplicated — each call is a new conversation. A Conversation row created by hand has no participants, and the list endpoint filters on participants__user, so nobody can see it.
- create_support — stapel_chat.services.create_support
  instead of: stapel_chat.models.Conversation.objects.create
  Open a support thread for a customer — unassigned, support_status=open, customer seeded as the participant, which is exactly the shape support_queue() selects on. Call it from an order/ticket flow instead of writing the row: a support conversation with a blank status or no participant is one no operator will ever be shown.
- post_message — stapel_chat.services.post_message
  instead of: stapel_chat.models.Message.objects.create
  THE send path, and the only sanctioned way a message comes into being: allocates the next per-conversation seq under a select_for_update row lock, persists the row and writes the chat.message outbox event in ONE transaction (mutate_and_emit), schedules best-effort realtime fan-out on_commit, retries on a seq collision. Inserting a Message with a seq you chose yourself bypasses the counter, breaks the gapless total order that anchor-paginated history and realtime resume both anchor on, and emits nothing at all.
- support_queue — stapel_chat.services.support_queue
  The operator queue as a queryset: unassigned support conversations still open or pending, oldest first. Pass qs= to pre-scope it (typically the SCOPE_PROVIDER filter) rather than re-deriving the filter — "what is still waiting for an operator" is policy, and a second copy of it drifts from the one assign_operator enforces.
- unread_count — stapel_chat.services.unread_count
  instead of: stapel_chat.models.Message.objects.count
  Messages past a participant's marker that somebody else authored — system lines (null sender) and the participant's own messages are excluded, and that exclusion is the whole difference from a naive count: a hand-rolled seq__gt count lights the badge for your own message and for every "operator assigned" system line.

## Extension points — what a product replaces, fork-free
- SCOPE_PROVIDER [dotted_path]
  Swap how the opaque scope_key (workspace/org/tenant) is resolved from the request and enforced on querysets — the default is a single global scope; a host may return the active workspace_id so conversations are partitioned per tenant.
- chat.message [comm_event]
  Emitted (transactionally, via the outbox) whenever a message is appended. Realtime delivery, search indexing and notifications subscribe without any coupling in the engine.
- chat.support.assigned [comm_event]
  Emitted when a support conversation is claimed by an operator — routing and operator-notification layers subscribe.
- serializer_seams [class_override]
  Every view declares request/response serializer seams (SerializerSeamMixin) — subclass the view, override the attribute, remount the URL.

## Fits with — fleet dependencies
- stapel-auth (optional) — every endpoint requires an authenticated user (IsAuthenticated); stapel-auth is the shelf's session issuer — any stapel-core-compatible JWT issuer satisfies the check
- stapel-cdn (optional) — messages carry opaque attachment keys only; a file/CDN module (or any host storage) owns the bytes those keys point at
- stapel-core (required) — comm bus (chat.message / chat.support.assigned emits via the outbox), JWT authentication (HTTP + optional Channels), AppSettings config layer, anchor pagination

## HTTP operations (10) — call by operationId, never by a typed path
Paths are relative to `/chat/api/v1/`.
### Chat
- POST /conversations — chat_api_v1_conversations_create
- GET /conversations — chat_api_v1_conversations_list
- POST /conversations/{conversation_id}/messages — chat_api_v1_conversations_messages_create
- GET /conversations/{conversation_id}/messages — chat_api_v1_conversations_messages_list
- POST /conversations/{conversation_id}/read — chat_api_v1_conversations_read_create
- GET /conversations/{conversation_id} — chat_api_v1_conversations_retrieve
### Chat support
- POST /support/conversations/{conversation_id}/assign — chat_api_v1_support_conversations_assign_create
- POST /support/conversations/{conversation_id}/reopen — chat_api_v1_support_conversations_reopen_create
- POST /support/conversations/{conversation_id}/resolve — chat_api_v1_support_conversations_resolve_create
- GET /support/queue — chat_api_v1_support_queue_list

## Error codes (54) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.chat_attachments_disabled [400] fix_input
- error.400.chat_body_too_long [400] fix_input
- error.400.chat_empty_message [400] fix_input
- error.400.chat_invalid_direct [400] fix_input
- error.400.chat_invalid_kind [400] fix_input
- error.400.chat_invalid_reply [400] fix_input
- error.400.chat_kind_disabled [400] fix_input
- error.400.chat_not_support [400] fix_input
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.chat_not_operator [403] retry
- error.403.chat_not_participant [403] retry
- error.403.forbidden [403] retry
- error.403.network_blocked [403] contact_support
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.chat_conversation_not_found [404] retry
- error.404.not_found [404] retry
- error.404.verification_challenge_not_found [404] verify
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.chat_already_assigned [409] fix_input
- error.409.conflict [409] fix_input
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.mandate_unavailable [503] retry
