symfonic.services.conversation¶
conversation ¶
Conversation, session, transcript, and checkpoint services (T3.4.1).
History strategies, session identity, transcript persistence and query, checkpointer readiness, restart recovery, and state overrides — separated from agent orchestration, each behind its own port.
Two contracts run through the whole package and are the reason it is one package rather than four:
Bidirectional format compatibility. During the migration window, state
written by the legacy path is readable and resumable by these services, and
state written by these services is readable and resumable by the legacy path.
The mechanism is an additive envelope under a single reserved metadata key
(:mod:~symfonic.services.conversation.compat) plus a session row that
renders exactly the legacy five keys. Wave rollback depends on both halves.
Replay-only migration under an explicit horizon. The registry
(:mod:~symfonic.services.conversation.registry) is the single authority on
which checkpoints exist; the horizon
(:mod:~symfonic.services.conversation.horizon) says how long legacy-format
state stays migratable; and the only crossing is replay from a contract-tested
safe boundary (:mod:~symfonic.services.conversation.migration). Arbitrary
mid-frame state is never translated, and expiry is always explicit and always
carries a support route.
Library mode gets its own horizon and its own journey
(:mod:~symfonic.services.conversation.library): the replay path ships in the
package and survives the T4.4.6 retirement of the legacy engine, and adopter-
local artifacts are keyed by package version rather than by the operated
platform's calendar.
AmbiguousThreadKeyError ¶
Bases: SessionIdentityError
A thread key's tenant segment cannot be reconstructed unambiguously.
Raised in both directions, because the ambiguity is symmetric:
- forward — a
tenant_id/sub_tenant_idcarrying the thread-key separator is refused, so no new key can be minted that parses back as a different tenant, and - backward — a key read out of a backend whose tenant segment is not provably whole (more separators than the derivation puts there) is quarantined for attribution: it may still be parsed positionally and resumed, but it may not be used to name a tenant.
The migrated path never re-attributes such state. Pre-existing state
written by the legacy path under a separator-bearing tenant stays readable
at its key and stays quarantined for tenant attribution until an operator
records the owning tenant explicitly (CheckpointRef.tenant_id), which
is the one attribution this package treats as authoritative.
CalendarHorizon
dataclass
¶
Operated-platform horizon: a published cutoff and a notice window.
CheckpointAdapterPort ¶
Bases: Protocol
The durable-state handle this service governs.
Structurally a superset of the kernel's CheckpointerPort (readiness,
flush, close) plus the two reads restart recovery needs. Adopters
implement it with their own saver; nothing here knows what a checkpoint
physically is.
CheckpointFormatError ¶
Bases: ConversationServiceError
Persisted state does not match a format this package can vouch for.
Covers an envelope from a future format version, a corrupt envelope, and a reserved key already occupied by something else. Every one of those is a refusal, never a silent downgrade to "assume legacy".
CheckpointRef
dataclass
¶
CheckpointRef(thread_id: str, checkpoint_id: str, writer_line: WriterLine, format_version: int, created_at: datetime, package_version: str | None = None, safe_boundary: bool = False, finalized: bool = True, expired: bool = False, expiry_reason: str | None = None, tenant_id: str | None = None)
One checkpoint, as the authoritative registry knows it.
writer_line and format_version are what make a rollback decidable;
expired plus expiry_reason are what make an expiry explicit rather
than an absence.
attribution_is_certain
property
¶
Whether this ref can name its tenant without guessing.
Two ways to be certain: the tenant was recorded on the ref (the migrated writers do this), or the thread key carries exactly the two separators the derivation introduces, so its first segment is provably the whole tenant id.
owning_tenant
property
¶
The tenant this checkpoint belongs to. Refuses to guess.
:class:SessionIdentity refuses to mint a key from a separator-
bearing tenant, but that guard never applied to a key read back out of
a backend — the legacy engine derived keys without validating either
id. So for a key with more separators than the derivation introduces
(acme:eu:_:s1), the first segment may be a prefix of the tenant
rather than the tenant, and this raises instead of answering.
That matters because this value addresses expiry notices
(TenantNotificationPolicy): answering 'acme' here would deliver
tenant acme:eu's thread id and checkpoint id to a different tenant.
A recorded tenant_id is the authoritative escape hatch and is used
whenever present.
owning_tenant_or_none
property
¶
:attr:owning_tenant, or None where it would refuse.
For callers that must partition a mixed set — notify what can be addressed, quarantine the rest — rather than abort the whole batch.
expire ¶
Return an expired copy. Expiry is recorded, never a deletion.
CheckpointRegistry ¶
The single source of truth for which checkpoints exist and may resume.
Source code in src/symfonic/services/conversation/registry.py
expire ¶
Mark a ref expired. Recorded, never deleted.
Source code in src/symfonic/services/conversation/registry.py
finalize ¶
Close the crash window on a ref. Unknown refs are refused.
Source code in src/symfonic/services/conversation/registry.py
freeze_issuance ¶
Close new issuance. The first reason is the one that is kept.
A second freeze does not overwrite the first: the operator who declared the drain is the one whose reason belongs in the record.
Source code in src/symfonic/services/conversation/registry.py
mark_safe_boundary ¶
mark_safe_boundary(thread_id: str, *, sequence: int, digest: str, writer_line: WriterLine, boundary_id: str | None = None) -> SafeBoundaryMarker
Record a replayable boundary. Idempotent by derived id.
boundary_id re-adopts a boundary under the id its writer derived,
which is what makes the marker idempotent across a restart as well as
within one process: the (sequence, digest) it was hashed from are not
recoverable from a bare listing, so re-deriving mints a second id.
Source code in src/symfonic/services/conversation/registry.py
migration_lock ¶
The lock every migrator must hold while replaying this legacy ref.
It lives here, not on the migrator, for the same reason
:class:MigrationLink does: the window it closes spans an awaited
replay whose side effects the port commits before any link exists, so
a lock scoped to one migrator instance serialises nothing once two
migrators share this registry. record_migration is first-wins, but
by then the loser has already replayed. Cross-process exclusion is
out of scope for an in-memory registry; a durable implementation
supplies it by making this lock durable.
Source code in src/symfonic/services/conversation/registry.py
migration_of ¶
The replay this legacy ref already produced, if any.
reconcile_crash_expiry ¶
Expire issued-but-never-finalized state older than grace.
Idempotent: an already-expired ref is not reported a second time, so a reconciler on a timer does not manufacture a rising expiry count.
Source code in src/symfonic/services/conversation/registry.py
record_migration ¶
record_migration(thread_id: str, legacy_checkpoint_id: str, *, checkpoint_id: str, boundary_id: str) -> MigrationLink
Link a legacy ref to the checkpoint its replay produced.
Idempotent, and the first link wins: if two racing replays somehow both landed, the one already recorded is the one every later reader resolves to, so a legacy ref never resolves to two different migrated checkpoints depending on who asks.
Source code in src/symfonic/services/conversation/registry.py
refs_for ¶
Every known ref for a thread, oldest first.
Source code in src/symfonic/services/conversation/registry.py
register ¶
Record a checkpoint. Idempotent; refuses a changed writer line.
Re-registering an identical ref while frozen is explicitly allowed: an idempotent replay of state that was already issued is not issuance, and refusing it would make a retry during a drain look like a new checkpoint.
issuance=False says "this ref records durable state that already
exists; registering it is bookkeeping catch-up", so the freeze does not
apply. Two callers may say it: :meth:register_replay (which keys the
claim to a legacy ref already accounted for) and restart adoption
(whose rows were read out of the backend, so refusing them prevents
no state from existing — it only leaves the thread quarantined).
Source code in src/symfonic/services/conversation/registry.py
register_replay ¶
Register the checkpoint a safe-boundary replay produced.
This is the write a retirement drain is made of, so it survives an issuance freeze — otherwise freezing issuance to drain legacy state would guarantee nothing could ever be drained, and the freeze's stated purpose ("leaving reads and idempotent replays open, so a retirement drain can finish") would be unreachable.
The exemption is keyed, not blanket: while frozen, the legacy ref must already be authoritative here. A replay of something this registry has never seen is new state wearing the word "migration".
Source code in src/symfonic/services/conversation/registry.py
CheckpointService ¶
CheckpointService(*, adapter: CheckpointAdapterPort | None, registry: CheckpointRegistry | None = None, clock: object | None = None)
Owns readiness and teardown for one run's durable conversation state.
Source code in src/symfonic/services/conversation/checkpoint.py
flush_failures
property
¶
Flush failures, as safe-to-log text. Never exception objects.
close
async
¶
Release the durable handle, flushed or not. Idempotent.
Source code in src/symfonic/services/conversation/checkpoint.py
ensure_ready
async
¶
Open durable state once. Idempotent on success, retried on failure.
The lock is what makes "once" true under concurrency: readiness may
open a pool or run a migration, and two coroutines of the same run that
both observed _ready is False would otherwise do it twice. The
second waiter re-checks after acquiring, so it costs one flag read on
the hot path once readiness is established.
Source code in src/symfonic/services/conversation/checkpoint.py
flush
async
¶
Push buffered writes. Failure is recorded, never raised.
Source code in src/symfonic/services/conversation/checkpoint.py
record_write
async
¶
record_write(thread_id: str, checkpoint_id: str, *, safe_boundary: bool = False, sequence: int | None = None, digest: str | None = None, package_version: str | None = None, tenant_id: str | None = None, finalized: bool = True) -> CheckpointRef
Register a checkpoint this service wrote as authoritative.
A safe-boundary write also marks the boundary, because a boundary nobody recorded is a boundary no future migration can replay from — and the moment a boundary is written is the only moment its sequence and digest are known for free.
That marker lives in a process-local registry, so the durable half
is the caller's: stamp registry.latest_safe_boundary(thread_id)'s
boundary_id and sequence into the envelope
(:func:~symfonic.services.conversation.compat.encode_envelope) that
goes out with the state. Restart adoption re-adopts the boundary under
exactly that id and never re-derives one, so the id a migration replays
from is the id a writer marked — before and after a restart alike.
finalized=False opens the crash window on this ref. Its durable
half is the caller's in the same way: stamp
encode_envelope(..., finalized=False) into the metadata that goes
out with the write, and a finalized=True envelope when the write is
closed. A restart adopts what the envelope says, so an unfinalized row
left behind by a crash that ended the process is expired by
reconcile_crash_expiry with a reason rather than resumed mid-frame.
Source code in src/symfonic/services/conversation/checkpoint.py
CompatibilityReport
dataclass
¶
CompatibilityReport(direction: str, writer_line: WriterLine, format_version: int, readable_by_legacy: bool, readable_by_migrated: bool, reason: str)
Which direction a piece of state can cross, and why.
Carries no state content — only the verdict — so it is safe to log next to a tenant identifier.
ConversationCapability ¶
Resolves a session identity and a history policy into one plan.
Source code in src/symfonic/services/conversation/capability.py
plan_for ¶
Deterministic for a given identity: no clock, no counters, no ids.
Reproducibility is the property that lets a parity harness compare the legacy and migrated paths turn for turn.
Source code in src/symfonic/services/conversation/capability.py
ConversationPlan
dataclass
¶
What one session's conversation looks like before any turn runs.
to_legacy_overrides ¶
Config overrides plus the thread key, for the legacy adapter.
ConversationServiceError ¶
Bases: SymfonicError
Root of the conversation/session/transcript/checkpoint taxonomy.
ExpiryNotice
dataclass
¶
ExpiryNotice(tenant_id: str, thread_id: str, checkpoint_id: str, deadline: datetime, support_route: str)
What one tenant is told, carrying no conversation content.
ExportReceipt
dataclass
¶
Proof that a tenant's expiring state was handed back before expiry.
HistoryDirective
dataclass
¶
HistoryDirective(kind: HistoryKind, summarize: bool = False, window_messages: int | None = None, trigger_chars: int | None = None, keep_recent: int | None = None)
What a strategy decided, as an inert value.
default
classmethod
¶
The framework's documented default: summarize overflow.
Source code in src/symfonic/services/conversation/history.py
to_legacy_overrides ¶
Render as the legacy config fields, carrying no engine types.
Both mechanisms are always named. Leaving one unset would let whatever the adopter's config already held govern alongside this directive, which is precisely the "two limiters, unclear winner" ambiguity the strategy objects exist to remove.
Source code in src/symfonic/services/conversation/history.py
HistoryStrategy ¶
Bases: Protocol
A named, swappable policy for keeping a conversation in the window.
HorizonDecision
dataclass
¶
The horizon's answer for one artifact.
InMemorySessionStore ¶
The default store: partitioned by tenant, with a reverse owner index.
The reverse index is what makes a cross-tenant collision detectable without scanning every tenant — the legacy manager needed the same thing and it is the reason the storage layout is two maps rather than one.
Source code in src/symfonic/services/conversation/session.py
IssuanceFreeze
dataclass
¶
Why and when new issuance was closed.
IssuanceFrozenError ¶
Bases: ConversationServiceError
New checkpoint issuance is frozen; only reads and replays remain.
The freeze is how a retirement drain reaches a fixed point: no new state of the retiring shape can appear while the existing state is migrated out.
So the drain itself is not what this refuses. Registering the output of a safe-boundary replay, and adopting rows read back out of the backend after a restart, both stay open while frozen — a freeze that closed them would guarantee that nothing could ever be drained. Raised while frozen only for genuinely new state, including a "migration" of a legacy ref the registry never accounted for, which is new state under another name.
LibraryModeUpgrade ¶
LibraryModeUpgrade(*, registry: CheckpointRegistry, migrator: SafeBoundaryMigrator, horizon: PackageVersionHorizon, package_version: str | None = None)
Guides an adopter across the legacy-engine retirement boundary.
Holds no state of its own: the registry is the authority, the migrator performs the replay, and the horizon decides support. This class is the adopter-facing sequencing of those three.
Source code in src/symfonic/services/conversation/library.py
guidance ¶
Report, never raise. Guidance is what an adopter reads first.
Source code in src/symfonic/services/conversation/library.py
require_resumable ¶
Assert the artifact can be used as-is, or raise the reason it cannot.
Source code in src/symfonic/services/conversation/library.py
upgrade
async
¶
Perform the guided migration. Raises where guidance reported a stop.
The two refusals are distinct exception types on purpose:
:class:UnsafeBoundaryError is permanent for that artifact, while
:class:ResumabilityHorizonExpiredError is a statement about the
support window and names the route out of it.
Source code in src/symfonic/services/conversation/library.py
MigrationLink
dataclass
¶
MigrationLink(thread_id: str, legacy_checkpoint_id: str, checkpoint_id: str, boundary_id: str, at: datetime)
Which migrated checkpoint a legacy ref was replayed into, and from where.
The link lives in the registry rather than on the migrator because the question "has this legacy frame already been replayed?" outlives any one migrator instance. Answering it from an instance attribute means a second migrator — a second worker, a retry after a restart — replays a thread the first one already replayed, with whatever side effects the replay port committed.
MigrationOutcome
dataclass
¶
MigrationOutcome(thread_id: str, checkpoint_id: str, migrated: bool, already_migrated: bool = False, boundary_id: str | None = None)
What migrating actually did.
MigrationPlan
dataclass
¶
MigrationPlan(strategy: str, ref: CheckpointRef, boundary: SafeBoundaryMarker | None, decision: HorizonDecision)
What migrating this artifact would involve, before anything runs.
NullHistoryStrategy
dataclass
¶
No management: history grows to the model's own limit.
PackageVersionHorizon
dataclass
¶
Library-mode horizon: keyed by package version, never by the calendar.
ReconciliationReport
dataclass
¶
ReconciliationReport(at: datetime, grace_seconds: float, expired: tuple[CheckpointRef, ...] = (), inspected: int = 0)
What a crash-expiry pass expired, and on what grounds.
RecoveryDecision
dataclass
¶
RecoveryDecision(action: RecoveryAction, thread_id: str, reason: str, checkpoint_id: str | None = None, safe_boundary: SafeBoundaryMarker | None = None)
What to do with a thread's durable state after a restart.
ReplayPort ¶
Bases: Protocol
Re-executes a thread from a safe boundary and returns the new id.
A port because replay belongs to whoever owns the graph, not to the registry. This package decides whether and from where; it never decides how.
replay
async
¶
RestartRecoveryService ¶
RestartRecoveryService(*, adapter: CheckpointAdapterPort | None, registry: CheckpointRegistry, clock: object | None = None)
Decides resume / migrate / quarantine / fresh for a restarted thread.
Source code in src/symfonic/services/conversation/recovery.py
adopt
async
¶
Rehydrate the registry from a thread's durable state.
Provenance is read, never assumed: each row's envelope says which
line wrote it, and a row with no envelope is legacy at format version
0 — the positive statement :func:decode_envelope makes, not a guess.
A row whose envelope is corrupt or from a newer format is skipped, so
it stays unaccounted for and quarantines rather than resuming under an
invented provenance. Already-registered rows are left exactly as they
are: adoption never overwrites the registry's own record.
Safe boundaries are adopted only under the id the writer recorded in the envelope. A row that claims to be a boundary but names none leaves nothing to replay from, and inventing an id from its listing position would be worse than having none — the migrator would hand a replay port a boundary that never existed.
Returns the refs this call adopted, so a caller can log what a restart took ownership of.
Source code in src/symfonic/services/conversation/recovery.py
ResumabilityHorizon ¶
ResumabilityHorizonExpiredError ¶
Bases: ConversationServiceError
The artifact is past its published support horizon.
Always carries the support route in its message: an expiry an adopter cannot act on is indistinguishable from a bug.
Source code in src/symfonic/services/conversation/errors.py
SafeBoundaryMarker
dataclass
¶
SafeBoundaryMarker(thread_id: str, boundary_id: str, sequence: int, writer_line: WriterLine, created_at: datetime)
A contract-tested point a thread may be replayed from.
boundary_id is derived from the thread, sequence, and state digest, so
marking the same boundary twice — from a retry, a second process, or a
replayed migration — produces the same identifier and therefore one marker.
create
classmethod
¶
create(*, thread_id: str, sequence: int, digest: str, writer_line: WriterLine, created_at: datetime, boundary_id: str | None = None) -> SafeBoundaryMarker
Build a marker, deriving the id unless the writer's is supplied.
boundary_id is for rehydration only: it re-adopts a boundary under
the id the writer recorded, rather than re-deriving one from inputs
(sequence, digest) that a restarted process cannot recover.
Source code in src/symfonic/services/conversation/refs.py
SafeBoundaryMigrator ¶
Plans and performs replay migrations under a resumability horizon.
Source code in src/symfonic/services/conversation/migration.py
horizon
property
¶
The horizon this migrator decides under.
Readable because a caller that requires a particular horizon kind (library mode requires a package-version horizon) must be able to check the wiring when it is built, not discover it mid-replay.
migrate
async
¶
Replay from the boundary and register the result as migrated.
Everything from the "already migrated?" question to recording the link happens under one lock, because the question is only answered correctly while nobody else can be mid-replay of the same legacy ref.
Source code in src/symfonic/services/conversation/migration.py
plan ¶
Decide the strategy without touching the thread.
Order is load-bearing: the horizon is consulted before the boundary lookup, so an expired artifact reports expiry rather than reporting that its (irrelevant) boundary is missing.
Source code in src/symfonic/services/conversation/migration.py
SessionIdentity
dataclass
¶
Tenant + sub-tenant + session, and the thread key they derive.
as_configurable ¶
The graph-runner config shape. checkpoint_id only when resuming.
Source code in src/symfonic/services/conversation/values.py
SessionIdentityError ¶
Bases: ConversationServiceError
A session identity could not be derived, parsed, or trusted.
Raised rather than defaulted: a guessed tenant is a cross-tenant read, and a guessed thread id silently forks one conversation into two.
SessionIdentityService ¶
Derives and parses session identities. No state, by design.
for_scope ¶
Derive from an authenticated scope object, read by attribute.
Source code in src/symfonic/services/conversation/identity.py
from_thread_id ¶
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
SessionRecord
dataclass
¶
SessionRecord(session_id: str, tenant_id: str, created_at: datetime, last_active: datetime, message_count: int = 0, extra: tuple[tuple[str, Any], ...] = ())
One session row, in the migrated shape, with a legacy projection.
extra carries any key the legacy path wrote that this package does not
model. Dropping it would make a rollback lossy, which is the one thing the
bidirectional assumption forbids.
to_legacy_dict ¶
Exactly the keys the legacy SessionManager wrote, same types.
Source code in src/symfonic/services/conversation/values.py
SessionService ¶
SessionService(*, store: SessionStorePort | None = None, clock: object | None = None, max_per_tenant: int = MAX_SESSIONS_PER_TENANT)
Creates, finds, and ages session rows for one deployment.
Source code in src/symfonic/services/conversation/session.py
ensure ¶
Return the caller's session, or issue one.
A session id already owned by a different tenant is never joined: the asking tenant gets a fresh id and the owner's row is untouched. Guessing another tenant's id must not be a way into their session.
Source code in src/symfonic/services/conversation/session.py
list ¶
Newest activity first, matching the legacy listing order.
Source code in src/symfonic/services/conversation/session.py
touch ¶
Advance activity and count a message. A foreign id is a no-op.
Source code in src/symfonic/services/conversation/session.py
SessionStorePort ¶
SlidingWindowHistoryStrategy
dataclass
¶
Keep the most recent window_size messages; drop older ones.
StateEnvelope
dataclass
¶
StateEnvelope(format_version: int, writer_line: WriterLine, package_version: str | None = None, safe_boundary: bool = False, boundary_id: str | None = None, boundary_sequence: int | None = None, finalized: bool | None = None)
The provenance block attached to migrated-written state.
to_metadata_value ¶
Render as JSON-native scalars only.
Checkpoint metadata crosses a serializer this package does not own, so anything richer than a scalar is a portability bet on somebody else's codec.
boundary_id and boundary_sequence are written here, with the
state, because they are the only place a boundary's identity survives a
restart. The id is a hash of (thread, sequence, digest); a process that
comes up against a bare listing has none of those, so a boundary whose
id is not persisted alongside its state is a boundary the next process
can only guess at — and a guessed boundary id is one no writer ever
marked.
finalized is here for the same reason and answers the same class of
question: a crash that ends the process takes the registry's
in-memory finalization state with it, so a restart that could not read
finalization back would have to assume every durable row was a
completed write — and crash-expiry could then only ever see a crash
that left the process alive.
Source code in src/symfonic/services/conversation/compat.py
StateOverrides ¶
Splits run-config keys out of graph-state overrides.
split
classmethod
¶
Return (configurable, graph_state) without mutating the input.
A None for a configurable key is refused rather than dropped: the
caller meant to pass a thread id and computed nothing, and silently
continuing starts a brand-new thread under the same session.
Source code in src/symfonic/services/conversation/recovery.py
SummarizingHistoryStrategy
dataclass
¶
SummarizingHistoryStrategy(trigger_chars: int = _DEFAULT_TRIGGER_CHARS, keep_recent: int = _DEFAULT_KEEP_RECENT)
Summarize overflow into a running summary, keeping recent turns raw.
TenantNotificationPolicy ¶
TenantNotificationPolicy(*, registry: CheckpointRegistry, horizon: CalendarHorizon, export: Callable[[CheckpointRef], str] | None = None)
Decides who is notified, and what may still be exported.
Source code in src/symfonic/services/conversation/notification.py
expiring ¶
Refs the horizon would refuse once the cutoff passes.
Source code in src/symfonic/services/conversation/notification.py
export_expiring ¶
Export everything still inside the window. Nothing after it.
Same attribution rule as :meth:notices, for a stronger reason: a
receipt carries the state itself, so handing one to a guessed tenant
is a cross-tenant data release rather than a mis-addressed warning.
Source code in src/symfonic/services/conversation/notification.py
notices ¶
Notices for the current moment; empty outside the notice window.
Notifying earlier would train tenants to ignore the notice, and
notifying after the cutoff would be an obituary, not a warning.
Refs listed by :meth:unattributed are skipped: a notice addressed to
a guessed tenant is a cross-tenant disclosure, not a warning.
Source code in src/symfonic/services/conversation/notification.py
unattributed ¶
Expiring refs whose owning tenant cannot be named.
A thread key the legacy path wrote under a separator-bearing tenant
parses to a prefix of that tenant, so addressing a notice from it
would hand one tenant another tenant's thread and checkpoint ids.
Those refs are held back here rather than mis-addressed — and rather
than dropped, because an expiry nobody can be told about is exactly
the silent expiry the horizon exists to prevent. The operator route is
to record the owning tenant on the ref (tenant_id), after which it
notifies normally.
Source code in src/symfonic/services/conversation/notification.py
TranscriptQuery
dataclass
¶
TranscriptQuery(thread_id: str, speaker: Speaker = 'all', limit: int | None = None, index: int | None = None, since: datetime | None = None, until: datetime | None = None)
One transcript read. Validated at construction, not at the store.
TranscriptRow
dataclass
¶
TranscriptRow(index: int, role: TranscriptRole, content: str, message_id: str | None = None, timestamp: datetime | None = None)
One verbatim transcript row on the public surface.
index is the ordinal within the speaker-filtered view that produced
it, and timestamp is checkpoint granularity — None when the source
cannot resolve one. Both were load-bearing on the legacy surface.
TranscriptService ¶
Reads verbatim transcripts through a source port.
Source code in src/symfonic/services/conversation/transcript.py
read
async
¶
Filter, then select an ordinal, then cap — the legacy order.
limit is applied last and keeps the first rows of the resulting
view (rows[:limit]), exactly as the legacy read did. Rows keep the
ordinal of the speaker-filtered view they came from, so a capped or
ordinal-selected read still correlates with an uncapped one.
Source code in src/symfonic/services/conversation/transcript.py
TranscriptSourcePort ¶
TranscriptUnavailableError ¶
Bases: ConversationServiceError
The transcript cannot be served — no durable source, or no timestamps.
Distinct from "the transcript is empty", which is a legitimate answer.
UnsafeBoundaryError ¶
Bases: ConversationServiceError
The state is mid-frame and has no contract-tested safe boundary.
There is no translation path by design. Arbitrary mid-frame state carries node-local invariants nobody re-validated, so the only supported crossing is replay from a boundary that was tested as a boundary.
UpgradeGuidance
dataclass
¶
UpgradeGuidance(verdict: UpgradeVerdict, thread_id: str, checkpoint_id: str, reason: str, support_route: str, package_version: str, boundary: SafeBoundaryMarker | None = None, next_step: str = '')
What an adopter should do with one local artifact, and why.
decode_envelope ¶
Read the envelope, or state positively that this is legacy state.
Source code in src/symfonic/services/conversation/compat.py
describe_compatibility ¶
Answer both directions for one piece of persisted state.
Source code in src/symfonic/services/conversation/compat.py
encode_envelope ¶
encode_envelope(*, package_version: str, safe_boundary: bool = False, boundary_id: str | None = None, boundary_sequence: int | None = None, finalized: bool = True) -> StateEnvelope
Build the envelope for state this package is about to write.
A safe_boundary=True write should carry the boundary_id and
boundary_sequence that CheckpointService.record_write derived
(readable back as registry.latest_safe_boundary(thread_id)); without
them the boundary is recorded in this process only and does not survive a
restart.
finalized=False opens the crash window durably: stamp it on the
envelope that goes out with a write registered as unfinalized, and stamp a
finalized=True envelope when the write is closed. A row that is still
False when a later process adopts it is a write interrupted by a crash,
and the grace window — not an assumption — decides its fate.
Source code in src/symfonic/services/conversation/compat.py
installed_package_version ¶
The installed distribution version, or a conservative fallback.
Source code in src/symfonic/services/conversation/library.py
merge_metadata ¶
Attach the envelope additively, refusing to shadow anything.
Two refusals, and they are the same contract read from both ends: if
something already occupies the reserved key, this package does not know
whose it is, and overwriting it would corrupt state belonging to a writer
nobody has identified; and if the key holds an envelope from a newer
format than this package writes, stamping the older version over it would
destroy provenance a newer node depends on. :func:decode_envelope already
refuses to read future-format state — writing over it would make the
refusal cosmetic, and a downgrade is never silent here.
Source code in src/symfonic/services/conversation/compat.py
parse_version ¶
Parse major.minor.patch, tolerating a pre-release suffix.
Refuses anything it cannot read rather than sorting it low: a version that
silently compares as 0.0.0 would place every unparseable build inside
every support window, which is the wrong direction to fail.
Source code in src/symfonic/services/conversation/horizon.py
resolve_history ¶
resolve_history(strategy: HistoryStrategy | None, *, default: HistoryStrategy | None = None) -> HistoryDirective
Pick the governing directive: explicit strategy, then default, then the framework default.
A non-strategy argument is refused rather than duck-typed. Silently ignoring a misspelled strategy would leave the conversation unbounded while the caller believed it was windowed.
Source code in src/symfonic/services/conversation/history.py
tenant_segment_is_provable ¶
Whether this key's tenant segment is provably the whole tenant id.
The derivation puts exactly two separators in a key. A key carrying more
could have come from either an exempt session id (t:_:https://x) or a
separator-bearing tenant the legacy path never validated
(acme:eu:_:s1), and nothing in the key itself distinguishes the two.
Positional parsing stays deterministic for both — but attribution does
not, so this predicate gates naming a tenant, never reading the state.