Skip to content

symfonic.services.conversation.identity

identity

Session identity: one derivation site, and its inverse.

The legacy engine derived thread_id inline in three places. Consolidating them is the point of the extraction, but the formula is frozen: it is the key under which every pre-migration transcript and checkpoint already lives.

The inverse (from_thread_id) is what lets the registry, the reconciler, and the notification policy answer "whose state is this?" from a bare thread key read out of a backend.

ScopeLike

Bases: Protocol

Anything carrying a tenant, satisfied by shape.

Deliberately structural: the tenancy scope type lives outside this layer and must not be imported into it (T1.2.1 runtime-service row).

SessionIdentityService

Derives and parses session identities. No state, by design.

for_scope

for_scope(scope: Any, session_id: str) -> SessionIdentity

Derive from an authenticated scope object, read by attribute.

Source code in src/symfonic/services/conversation/identity.py
def for_scope(self, scope: Any, session_id: str) -> SessionIdentity:
    """Derive from an authenticated scope object, read by attribute."""
    tenant_id = getattr(scope, "tenant_id", None)
    if not tenant_id:
        raise SessionIdentityError("scope carries no tenant_id")
    return self.identify(
        tenant_id,
        session_id,
        sub_tenant_id=getattr(scope, "sub_tenant_id", None),
    )

from_thread_id

from_thread_id(thread_id: str, *, tenant_id: str | None = None) -> SessionIdentity

Parse a thread key back into its identity.

split(":", 2) on purpose: a session id may legitimately contain colons (adopters use URLs and composite keys), and only the first two separators are structural. Splitting greedily would corrupt exactly the ids an adopter cannot change.

This is a positional parse, not a tenant attribution. For a key the legacy path wrote under a separator-bearing tenant (which it never validated), the leading segment is a prefix of the tenant rather than the tenant, and no parse can tell that key apart from an exempt colon-bearing session id. Reading and resuming such a thread is unaffected — its key is unchanged — but naming its tenant is refused: see :attr:CheckpointRef.owning_tenant and :func:~symfonic.services.conversation.values.tenant_segment_is_provable.

Pass tenant_id when the caller already knows the tenant (from an authenticated scope, or from a ref that recorded it). The prefix is then verified rather than inferred, which is the one way a key with extra separators can be attributed. A separator-bearing tenant_id is still refused: such state is quarantined, never re-attributed.

Source code in src/symfonic/services/conversation/identity.py
def from_thread_id(
    self, thread_id: str, *, tenant_id: str | None = None
) -> SessionIdentity:
    """Parse a thread key back into its identity.

    ``split(":", 2)`` on purpose: a session id may legitimately contain
    colons (adopters use URLs and composite keys), and only the first two
    separators are structural. Splitting greedily would corrupt exactly
    the ids an adopter cannot change.

    This is a *positional* parse, not a tenant attribution. For a key the
    legacy path wrote under a separator-bearing tenant (which it never
    validated), the leading segment is a prefix of the tenant rather than
    the tenant, and no parse can tell that key apart from an exempt
    colon-bearing session id. Reading and resuming such a thread is
    unaffected — its key is unchanged — but naming its tenant is refused:
    see :attr:`CheckpointRef.owning_tenant` and
    :func:`~symfonic.services.conversation.values.tenant_segment_is_provable`.

    Pass ``tenant_id`` when the caller already knows the tenant (from an
    authenticated scope, or from a ref that recorded it). The prefix is
    then *verified* rather than inferred, which is the one way a key with
    extra separators can be attributed. A separator-bearing ``tenant_id``
    is still refused: such state is quarantined, never re-attributed.
    """
    parts = thread_id.split(":", 2)
    if len(parts) != 3:
        raise SessionIdentityError(
            f"thread id {thread_id!r} is not tenant:sub:session"
        )
    if tenant_id is not None:
        return self._verified(thread_id, tenant_id)
    tenant_id_part, sub, session_id = parts
    return SessionIdentity(
        tenant_id=tenant_id_part,
        session_id=session_id,
        sub_tenant_id=None if sub == "_" else sub,
    )