---
title: "ADR-091: Sessions are owned — an explicit actor context on every turn"
manual: "TYPO3 LLM Extension"
version: "0.35"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-llm:adr-091@0.35"
source: "Adr/Adr091ConversationActorContext.rst"
modified: "2026-09-16T22:09:16+00:00"
---

# ADR-091: Sessions are owned — an explicit actor context on every turn

-   *Status:* Accepted
-   *Date:* 2026-07-20
-   *Supersedes:* the ownership and configuration binding of [ADR-083](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-083@0.35)
-   *Authors:* Netresearch DTT GmbH

## Context

ADR-083 introduced conversation sessions and stated that a session is owned by
a backend user. The implementation never enforced it. `ConversationService::send()`
loaded a session by uuid and used it; the repository filtered by uuid alone. Any
caller holding a uuid could read another user's conversation history and continue
it — including through the `public: true` service any downstream extension can
reach.

Two further gaps came from the same root, an implicit caller:

-   **The bound configuration was decorative.** `startSession()` stored an
    `LlmConfiguration` identifier that `send()` never read; the turn went
    through `LlmServiceManager::chat()`, which resolves the *installation
    default*. A session opened against a locally hosted, tool-free configuration
    silently continued against whatever default the installation had — a different
    model, a different budget and a different guardrail set than the conversation
    started with.
-   **Sequence numbers were derived from a read-modify-write.** `$session->messageCount`
    was read, used as the next sequence, and written back. Two concurrent turns
    produced two rows with the same sequence, and the index on
    `(session, sequence)` was not unique, so the database accepted them and the
    replay order became undefined.

## Decision

**Every stateful entry point takes an explicit** `AiActorContext`.

```php
$actor = AiActorContext::backendUser($uid, $isAdmin, $groupIds);
$actor = AiActorContext::serviceAccount('nrllm-worker');   // CLI, scheduler, queue
$actor = AiActorContext::anonymous();                      // owns nothing, may do nothing

$session = $conversations->startSession($actor, 'Teaser für Seite 17', $config);
$reply   = $conversations->send($actor, $session->uuid, 'Kürzer, bitte.');
```

The actor is passed, not inferred from `$GLOBALS['BE_USER']`. A queue consumer
can therefore act for the user who queued the work instead of inheriting whoever
happens to be logged in, and the decision is testable without a backend
bootstrap.

`send()` enforces three rules in order:

1.  **Ownership.** The actor must own the session, be an administrator, or be a
    service account. A uuid is an identifier, never an authorisation.
1.  **Configuration binding.** The session's configuration identifier is resolved
    on **every** turn through `ConfigurationResolver::getActiveByIdentifierForActor()`,
    which applies the activity and BE-group guards against the *actor* rather than
    the ambient user. A configuration that was deactivated, deleted or newly
    restricted stops the conversation with an access error instead of quietly
    falling back to the default. Turns then run through the new
    `LlmServiceManager::chatForConfiguration()`, the message-list counterpart of
    `completeForConfiguration()`.
1.  **Attribution.** The turn is attributed to the acting backend user unless the
    caller set an explicit owner, so per-user budgets apply to conversations
    exactly as they do to one-shot completions.

**Sequence allocation moves into the repository, and the database decides.**
`(session, sequence)` is now a UNIQUE key; `appendMessageAtNextSequence()`
reads the next free slot, tries to take it, and retries on a unique-constraint
violation. `touch()` advances `last_activity` unconditionally but raises
`message_count` only when it grows, so a slower concurrent turn cannot report
the session back down. `uuid` is unique as well.

Resolving the identifier per turn was chosen over freezing a configuration
snapshot at `startSession()`: a tightened budget, a narrowed tool set or a new
guardrail policy must take effect on running conversations, not only on new ones.

## Consequences

-   **Breaking change for downstream consumers.** Both `ConversationServiceInterface`
    methods gained a leading `AiActorContext` parameter. In a 0.x line this is
    accepted rather than papered over with an implicit fallback, because the
    implicit path is exactly the defect: a caller that forgets the context would
    otherwise silently keep the old, unauthenticated behaviour.
-   A session opened **without** a configuration keeps the generic path and the
    installation default — the pre-ADR-083 behaviour for callers that never chose
    one.
-   `ConfigurationResolver` gains an actor-aware sibling to its user-less
    `getActiveByIdentifier()`. The two coexist: user-less callers (CLI resolving
    by identifier with no actor at all) keep the stricter refusal of restricted
    configurations.
-   The UNIQUE key on `(session, sequence)` is added to an existing table. An
    installation that already produced colliding rows through the race must
    resolve those duplicates before the database analyzer can apply the index.
-   Ownership is enforced in the service, not in the repository query. The
    repository keeps a uuid lookup; the service is the single place that decides
    entitlement, which keeps the rule visible and testable in one location.
-   Denying access raises `AccessDeniedException`. Session uuids are random v4
    values, so distinguishing "unknown" from "not yours" leaks nothing an attacker
    could enumerate, and the distinction is what an operator needs in a log.

This ADR supersedes the ownership and configuration paragraphs of [ADR-083](https://docs.typo3.org/permalink/netresearch/nr-llm:adr-083@0.35); the session model, retention story and system-prompt handling
described there are unchanged.
