- ID
- 1df45111-5512-4360-ac81-76648e81eb23
scagent Conversations: session-based ownership (anonymous funnel)
scagent Conversations: session-based ownership (anonymous funnel)
- CUSTOM_ID
- scagent-conversations-session-based-ownership-anonymous-funnel
This document describes how =scagent= conversations are scoped to either
an authenticated *=Customer=* or an opaque client-generated *anonymous
session identifier*, so funnel flows can persist conversations before a
user logs in.
Problem
- CUSTOM_ID
- problem
Previously, =Conversation= stored a required *=customer=* foreign key,
and the conversations API required authentication keyed off
=request.user.customer=. That model could not represent chats that
belong only to a browser or app session (for example signup funnel UX)
where no =Customer= exists yet.
Goals
- CUSTOM_ID
- goals
- Store conversations owned by *=Customer=* when the client is
authenticated, unchanged for existing behaviors.
- Store conversations keyed by a *=anonymous_session_id=* when there is
no customer, using an identifier generated and held by the client (not
Django's =session_key= unless the client chooses to use that value).
- Enforce access control so a caller can only list, read, or mutate
conversations that match their identity (customer or session),
analogous to the prior “no eavesdropping” guarantee for
customer-scoped rows.
Non-goals (current implementation)
- CUSTOM_ID
- non-goals-current-implementation
- *Post-login merge*: automatically reassigning anonymous conversations
to a =Customer= after signup is not implemented; that can be a
separate workflow if product needs funnel history surfaced in-account.
- *Rate limiting / signed tokens*: the API accepts opaque session
strings; additional abuse mitigation (throttling, HMAC-signed session
tokens from a funnel bootstrap service, etc.) is left to operational
and product decisions.
Data model
- CUSTOM_ID
- data-model
Implementation:
[[../../scagent/models.py][=scagent.models.Conversation=]].
| Field | Role |
|------------------------+--------------------------------------------------------------------------------------------------------------------------|
| =customer= | Optional =ForeignKey= to =app.Customer=. Set when the conversation is owned by a logged-in user with a related customer. |
| =anonymous_session_id= | Optional =CharField= (=max_length=64=, indexed). Opaque identifier from the client when there is no =customer=. |
*Integrity*
- Exactly one of =customer_id= *or* =anonymous_session_id= must be set
(database *=CheckConstraint=*, XOR).
- When =anonymous_session_id= is not null it must be *non-empty* (second
*=CheckConstraint=*).
- At the application layer, =Conversation.clean()= aligns with the same
rules.
=external_conversation_id= remains the agent's unique conversation id
(globally unique as before).
API behavior
- CUSTOM_ID
- api-behavior
Implementation:
[[../../scagent/views/conversations.py][=scagent.views.conversations=]].
Authentication and permission
- CUSTOM_ID
- authentication-and-permission
- *Authenticated users* with a linked *=Customer=*
(=request.user.customer=): full access to conversations where
=conversation.customer= matches that customer. The anonymous session
header is *not* used for authorization in this path (even if present).
- *Authenticated users* without a *=Customer=* (for example staff or
service principals): *cannot* use the anonymous session header to
access these APIs; they receive *403* even when
*=scagent.anonymous_session_auth_enabled=* is on and
*=X-Anonymous-Session-Id=* is sent.
- *Unauthenticated clients* can only use the conversations API when
dynamic config *=scagent.anonymous_session_auth_enabled=*
(=BoolDynamicConfigEnum.ENABLE_SCAGENT_ANONYMOUS_SESSION_AUTH=) is
active. When it is off, those requests receive *403* even if
*=X-Anonymous-Session-Id=* is present. When it is on, callers must
send a non-empty *=X-Anonymous-Session-Id=* on every request;
permission is denied if the header is missing or blank (*403*).
Header
- CUSTOM_ID
- header
| Header | Meaning |
|--------------------------+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| =X-Anonymous-Session-Id= | Opaque string (for example a UUID) that identifies the funnel session. Must be sent consistently for create, list, retrieve, =add_message=, and =tool_calls= for anonymous flows. |
Create *does not* accept =anonymous_session_id= in the JSON body; the
server sets it from the header.
Query scoping
- CUSTOM_ID
- query-scoping
- Customer path: =Conversation.objects.filter(customer=user.customer)=.
- Anonymous path:
=Conversation.objects.filter(anonymous_session_id=<header>, customer__isnull=True)=.
Wrong session or another user's conversation yields *404* on detail
actions (same pattern as before for cross-customer access).
Messages
- CUSTOM_ID
- messages
For user-authored messages (no =agent_identifier=), =Message.customer=
is set only when the request user is authenticated with a customer;
otherwise the message's =customer= stays null.
OpenAPI
- CUSTOM_ID
- openapi
=drf-spectacular= documents the optional =X-Anonymous-Session-Id= header
on the conversations viewset.
Security and logging
- CUSTOM_ID
- security-and-logging
- Client-generated ids can be guessed or enumerated at scale; treat
abuse controls (rate limits, signed tokens, funnel-only origins) as
follow-up work if traffic warrants it.
- Avoid logging raw session identifiers in high-volume logs; use
correlation ids or short hashes where project security guidelines
require it.
Migrations
- CUSTOM_ID
- migrations
- =scagent/migrations/0005_conversation_anonymous_session.py= ---
nullable =customer=, =anonymous_session_id=, XOR constraint.
- =scagent/migrations/0006_conversation_anon_nonempty_constraint.py= ---
nonempty constraint for =anonymous_session_id=.
Related code
- CUSTOM_ID
- related-code
- Admin: [[../../scagent/admin.py][=scagent.admin=]] --- list display
and search include =anonymous_session_id=.
- Tests:
[[../../scagent/tests/views/test_conversations.py][=scagent.tests.views.test_conversations=]]
--- =AnonymousConversationTestCase= (anonymous flows with the flag
enabled), =AuthenticatedWithoutCustomerConversationTestCase= (*403*
for authenticated users without a Customer even when the flag is on),
=AnonymousConversationAuthFlagDisabledTestCase= (unauthenticated
requests get *403* when the flag is off), and existing customer-scoped
cases.