Org Web Adapter

hungryroot/scagent/conversation-session-ownership.org

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.