ADR-188: Every conversation has a configuration from the moment it opens
- Status
-
Accepted
- Date
-
2026-09-04
- Authors
-
Netresearch DTT GmbH
Context
ADR-151 bound a conversation to the configuration it was
opened with, and Conversation says so in its own class docblock:
"the turn runs against the configuration the session was opened with, resolved
fresh each time so a deactivated or newly restricted configuration stops the
conversation instead of silently continuing on the installation default".
The binding is real, and it has a hole exactly one turn wide.
start accepts a null configuration and writes an empty
configuration_. Everything that follows then reads that emptiness
as "no binding":
resolvereturns null,Turn Configuration () - so the context-window fit of ADR-121 is skipped entirely — turn 1 of that conversation is budgeted against nothing,
- so
dispatchfalls through to the generic() chat, where() Llmresolves the installation default itself,Service Manager - and the skill block of that configuration is injected by the manager into a
transcript this service has already sized without it. The
skilldocblock records that as a known limit: "the manager resolves the installation default itself and injects that configuration's skills — this service never learns which configuration that is, so it cannot account for its block."Block For ()
So the conversation does run against a configuration; it simply runs against one nobody wrote down, chosen one layer lower, one turn later, and re-chosen on every turn. Change the installation default halfway through and the same conversation continues on another model, another budget and another guardrail set — the outcome ADR-151's binding exists to prevent, reachable by not passing an argument.
Decision
A session is bound when it is opened, or it is not opened.
startends with a concrete configuration: the caller's if one was given, otherwise the installation default resolved at that moment, and the identifier is persisted with the row.Session () - Omitting the argument therefore no longer means "unbound". It means "the installation default, as it stands now" — a defaulting rule, not an absence.
- When neither resolves, the session is refused with
Configuration. No row is written. The alternative is a row with an empty identifier, which is the state this record exists to remove, and the caller would learn about the missing default one turn later from a different error anyway.Not Found Exception - A session opened before this rule binds itself once, on its next turn, and keeps running unbound if the installation still has no usable default. An improvement that cannot be made is not a reason to end a conversation someone is in the middle of.
Why no upgrade wizard
A wizard would write the installation default as it stands when the wizard runs into every unbound session. For a conversation nobody ever continues that is a decision made for nothing; for one that is continued it is the same decision the next turn makes anyway — one turn earlier and, decisively, without the actor. A restricted default would then be guessed at rather than evaluated.
Binding on the next turn has the actor in hand, so
Configuration can evaluate the
restriction instead of refusing outright the way its actor-less sibling
resolve must.
The write is conditional on the row still being unbound —
WHERE uid = ? AND configuration_ — so two concurrent turns of
the same legacy session cannot re-point a conversation the first one already
bound. The database decides, not the ordering of two reads.
And the turn that loses that race follows the row, not its own read. The losing write is a silent no-op, so a turn trusting what it resolved would fit and dispatch against a configuration the session is not bound to — the binding would hold for the row and not for the run. The bind is therefore followed by a re-read, and the identifier that comes back is what the turn uses. It differs from the resolved one only when the installation default moved between the two reads, which is exactly the case worth being right about.
Consequences
- Behaviour change on a public method.
startnow throws where it previously succeeded, on an installation with no usable default. Its signature is unchanged, so the frozen surface (ADR-127) does not move; the CHANGELOG announces the behaviour.Session () - The generic path is gone from conversations. Every turn of every session
now dispatches through
chat. The fit runs on turn 1, and the skill block it budgets for is the one that is actually injected — closing the limitFor Configuration () skilldocuments.Block For () - A session can now fail on its second turn where it previously drifted. A conversation bound to a configuration that is later deactivated stops, which is ADR-151's intent; before, an unbound one silently moved to whatever the default had become. The louder behaviour is the correct one.
ConfigurationgainsResolver resolve. It is the actor-aware sibling ofDefault For Actor () resolve, and the two differ in exactly one case: a group-restricted default, refused there because there is nobody to check, evaluated here because there is.Default Configuration () - A criteria-mode default is usable. It carries no
llm_by design — it picks one per call — andmodel Configuration, which every later turn goes through, does not ask for one either. Requiring a model here would have made a valid criteria default unable to open a session at all, while the identical configuration works for every session that names it explicitly. A model is required of a fixed-mode configuration and of nothing else.Resolver:: get Active By Identifier For Actor ()