symfonic.services.conversation.compat¶
compat ¶
Bidirectional checkpoint / session / transcript format compatibility.
The migration-window assumption, in both directions: state written by the legacy path is readable and resumable by the migrated services, and state written by the migrated services is readable and resumable by the legacy path. Wave rollback depends on the second half as much as the first.
The mechanism is deliberately the least clever one available: an additive envelope under a single reserved key. A legacy reader that ignores keys it does not know sees byte-identical metadata; a migrated reader that finds no envelope treats the state as legacy. Nothing is rewritten, nothing is moved, and there is no version of this file that mutates a legacy field.
Session rows use the same discipline at a different level: the migrated row
renders exactly the legacy five keys, and any key the legacy path added rides
along in SessionRecord.extra so a rollback loses nothing.
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.
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
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
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.