The transcript is a value
The provider-facing history is a persistent value shared by reference across
the fold, and owned genai wire values are manufactured at exactly one door,
at most once per HTTP attempt. A live 36-session fleet run showed ~12 MB of
record.jsonl becoming a 2303 MB peak footprint — MALLOC_SMALL churn, no
leak. The class of defect: the manufactured view, an O(|history|) owned
materialisation of immutable data recomputed inside the O(steps × attempts)
loop — up to three whole-history deep copies per deliberation step
(render_messages, build_cached_request, and a clone inside the retry
closure), the last of which bought nothing even on its own terms: genai
consumes a ChatRequest by value per call, so a kept template existed only to
be cloned again by a retry.
The property
Sharing follows immutability. The committed transcript crosses module boundaries only by shared reference. An owned whole-history value exists at exactly one named door, manufactured at most once per HTTP attempt — and at one other, the mnemon child seed, where ownership genuinely transfers.
Rejected candidates
- Borrowed projection (
&ChatMessageiterator over the ledger). The projection renders underLogCell’s lock, which panics on contention rather than blocking; a borrow held across a multi-minute network round trip would pin that lock while the streaming path itself needs to take it. The lifetime forbids it. Also incomplete: the projection interpolates repair stubs that exist in no ledger slot, so it is not a pure subsequence to borrow. - Owned snapshot per step. The status quo — the defect.
- Message-level interning (
ProtocolholdingArc<ChatMessage>). Rejected because it puts sharing plumbing into the verbatim wire payloadProtocolis deliberately kept as, and nests two sharing granularities once spans are also shared for no gain the span does not already give. - The rendered view as primary state (a running accumulator patched per event). Rejected: overturns the recompute invariant the model fold already relies on for correctness, and a pure memo suffices without it.
- Pre-serialised body. Impossible at genai’s seam —
exec_chat_streamtakes an ownedChatRequest, not bytes — and would trade a typed boundary for an untyped one.
The decision
The value
record/model.rs defines Transcript: private fields, Vec<Arc<[ChatMessage]>>
segments plus a cached byte length. Clone is Arc bumps. No method yields an
owned ChatMessage; the only routes back to owned messages are the wire door
and the mnemon child seed (Memo::inherited_context_messages).
The model fold’s memo caches each closed span’s rendering once, keyed by
span id and its end index (SpanRender) — a span id never recurs, so (id, end) determines a rendering globally and forever, and staleness is
inexpressible. The renderer is split so nothing can smuggle a flag into the
cached path:
render_closed_entrytakes the full range and repairs its end; no flags parameter exists to vary, so the memo’s key is the whole of its input.render_tailcovers only the last, still-growing span; it takes the two retroactive flags (omit_tail_assistant) and is never cached.
Memo::transcript() (the model_messages replacement) assembles the digest
segment, render_closed_entry per closed span through the memo, and
render_tail fresh for the live tail. history_bytes and context_survey
read the cached per-segment byte counts; only the tail is ever re-serialised.
The wire door
exarch/src/provider/wire.rs is the one place an owned genai::ChatRequest
is made:
Sealed(ChatRequest)— deliberately notClone.manufacture(adapter, system, transcript: &Transcript, tail: Option<ChatMessage>, tools: &[Tool]) -> Sealedclones each shared message once into a freshVec, applies the Anthropic cache-control marks to that owned copy alone (never to the shared segments), prepends the system message, and attaches the tool set.tailcarriessummarize’s trailing instruction — a freshly minted message, never a second clone site.
Engine::complete and Engine::summarize (provider/stream.rs) hold &str
system prompt and &Transcript and call manufacture inside the retry
closure, per attempt. There is no request_template in scope for either path
any more, and no pre-made request to clone: cloning a request is no longer
expressible on this path.
Settled against the locked genai 0.7.0-beta.19 source
(adapter/adapters/anthropic/adapter_shared.rs:110-132,325-341, recorded in
manufacture’s doc comment): the Anthropic adapter consumes the request by
value and reads msg.options.and_then(|o| o.cache_control) purely as a value;
ChatMessage carries no id, and neither the adapter (a unit struct) nor the
client holds anything across calls. No behaviour is keyed on message
identity, so marking a fresh clone per attempt is safe. The system prompt
stays an in-sequence ChatMessage::system on Anthropic rather than
ChatRequest.system, because the in-sequence form is load-bearing for the
two-anchor cache scheme: the request-level field is barred from a
cache-control mark, while an in-sequence System-role message keeps one, and
the request-level auto-mark defers to any message-level breakpoint — which the
last-two-messages marks guarantee exist. Every other adapter keeps the
dedicated system field.
Seam changes rippled mechanically: Provider::complete/summarize
(provider.rs:135,177) take &Transcript; CompactionPlan.prefix
(agent/event.rs:269-271) is a Transcript, not a Vec<ChatMessage>;
AgentLog::render_messages (agent/event.rs:555) returns
Result<Transcript, String>.
The prose
Agent::{system, system_base} (agent.rs:99,101,105) are Arc<str>: a
fork’s inheritance and every avatar’s per-step read of the ~38 KB system
template are refcount bumps, not copies. The per-attempt system-message copy
inside manufacture remains — the genai floor, below.
The genai floor
genai::Client::exec_chat_stream/exec_chat consume an owned ChatRequest,
and the system slot is owned too, whichever field carries it. One
whole-history copy per HTTP attempt — the ~38 KB system string included — is
therefore the boundary’s unavoidable price; Arc<str> reaches the per-fork
and per-avatar copies and cannot touch this one. A retry storm still pays it
per attempt, bounded by the retry budgets. Everything above that floor —
the render-time copies, the kept request template, the double clone inside
the retry closure — is what this decision removes.
Enforcement
- Types.
Transcript’s fields are private and it yields only&ChatMessage;Sealedis notClone;ChatRequestis named only inprovider/wire.rs. A future per-step deep clone of history requires either a newVec<ChatMessage>boundary (visible in review) or.clone()ing through the iterator (caught by nothing automatic — named honestly, not closed by a lint: clippy’sdisallowed-methodscannot scope a trait method,ChatMessage::clone, to one impl). - Tests landed with
wire.rsandrecord/model.rs, exercised by the ordinarycargo test:manufacture’s cache-control placement and system handling per adapter, that it leaves the source transcript untouched, and the existing model-fold projection tests that assert the transcript’s message sequence through edits, folds, repairs, and resume — narrower in scope now that flag staleness is closed atrender_closed_entry’s signature rather than defended by convention.
The source-scan meta-test, allocation-budget test, and ledger-residency test the originating plan proposed were judged not worth their weight: the types carry the guarantee.
See also
session-record (the transcript-as-value
narrative and the renderer split in the model fold),
provider-fault-recovery (a retry now
re-manufactures the request rather than cloning one),
provider (the wire door and the transport it sits
under), frontend (the model fold and AgentLog),
shared-transport (the sibling decision
that ruled out a deeper Engine-level seam for the same reasons this one
keeps the projection-level boundary honest),
surface-reads-writes-execs
(the sibling type-enforced ownership boundary this follows in shape: private
fields plus a closed door, not a convention).