There are only two things worth persisting: the Agent (a field of the Conversation) and the Conversation (ConversationState → base_state.json), plus a little server bookkeeping. meta.json is literally the create-request re-persisted.
One source of truth.
The companion study showed the agent-server writes the whole agent to disk twice and that the two copies drift. This is the plan to end that — derived, not asserted: a field-by-field partition of what each file should own, the one rule that keeps it enforced by a CI guard test, and a phased path that deletes the duplication without breaking the client or the (recently hard-won) cheap conversation list.
Read the study first. This page assumes Two files, one truth — where the copy is made, how it produced the timeout-revert bug (#4032), and the destination. That page states the goal and carries the compact build plan in its §12; this page is the deep version: the partition re-derivation, the two Phase-1 traps traced to the line, and the one SDK change everything else rides on.
1The short version
If a field is on ConversationState, it must not be authoritative in meta.json. That is enforceable by a five-line assertion over the models' own metadata — no judgement, no repro needed.
Ship the guard test xfail-ed at today's 9-field overlap, then let each phase shrink it. "Once and for all" becomes a property CI defends, not a claim in a PR description.
The study proved the mechanism. What was missing was the part that makes a fix stick: an exact account of which file should own each field, a rule that a reviewer can't accidentally violate, and a sequence that never regresses the three perf PRs (#4201/#4100/#4202) the codebase just landed to make listing cheap. That's this page.
2Four things that move the difficulty
Re-reading upstream/main surfaced four facts the study's framing didn't yet carry. Two make the fix easier than it looks; two make it harder. All four change where the real work is.
2.1 The HTTP API is already base_state-authoritative — easier
_compose_conversation_info() builds every conversation response from ConversationState, not from the meta snapshot:
return ConversationInfo(
**state.model_dump(mode="json"), # <- agent comes from base_state.json
title=stored.title, metrics=stored.metrics, created_at=stored.created_at, ...
)
So ConversationInfo.agent is fed by base_state.json. Removing agent from meta.json changes zero bytes of any API response. agent-canvas reads info.agent.kind / .acp_server / .acp_model / .llm.model (agent-server-adapter.ts:305,314,340-343) — all still served, unchanged. This kills the biggest perceived blocker: the client contract is not in the way.
2.2 The cheap-listing argument is alive — and was just reinforced
A correction I owe the record. My first draft claimed "meta exists to keep listing cheap" was already false. Engel pushed back and the code sided with him. Fixing it in place, because the wrong version pointed the plan at the wrong end state.
#4201 landed 2025-07-23 — days before the study's rewrite — and it is a deliberate investment in exactly the direction I'd called dead: it replaced a per-conversation full ConversationState load with a signature-gated raw read of one enum. Its docstring names the cost I was waving away:
"Validating the full ConversationState costs an agent, LLM config, workspace, secret registry and stats blob to reach one enum field."
With #4100 (lazy hydration) and #4202 (idle eviction) beside it, the trajectory is unambiguous: less state loading, not more. What actually survives of my point, stated narrowly:
| Path | Loads base_state? | Bearing on the plan |
|---|---|---|
| sort / filter / count / paginate | No — from the in-memory catalog; status is a gated one-enum read, skipped when the stat() signature is unchanged | meta's index role is real. Don't break it. |
composing the ≤limit page items | Yes — full model_validate_json per item; #4201 did not touch this | the only claim Phase 1 needs: reading the agent from base_state for the page adds zero new reads |
So Phase 1 is free on listing performance and the catalog must stay answerable without loading state. Compatible. The error was generalising the second row into a verdict on the first — and it flips Option B from "tidier end state" to actively regressive: folding meta into base_state would delete the index #4201 just built.
2.3 The two agent blobs are not copies — they're two things wearing one type
This is the finding that reframes the whole problem.
meta.json's agent is the declarative launch spec: what the caller asked for, pre-merge.base_state.json's agent is the resolved runtime agent: post-merge._ensure_plugins_loaded()merges plugin skills, expands MCP config, and writes the merged agent into state (which autosaves).
They were never two copies of one value that drifted — they are two different values that were never given different names. That is exactly why neither shipped bandaid can be right: #4028 copies resolved → declarative, #4219 reads declarative ← resolved, and both had to hand-scope to llm+condenser so they wouldn't move the merged form. That coincidence is the type confusion showing through. Every future field would need the same manual adjudication.
2.4 The resolved agent has secret values baked in — harder
_ensure_plugins_loaded calls expand_mcp_variables(..., expand_defaults=True) and persists the result. mcp_config is a plain dict — no SecretStr, so no redaction on dump. So base_state.json's agent carries resolved secret values that meta.json's does not. Two consequences, both real:
base_state.jsonis the more dangerous file for the trajectory-export leak (#4216/#4217), not the safer one.- A naive "resume entirely from base_state" would freeze rotated secrets forever — the baked-in values have no
${...}left to re-expand.
This is the code-level reason the declarative copy has value, and the concrete link to #15298. The endgame isn't "one agent blob" — it's one resolved agent that is safe to persist because it holds references, plus one declarative launch spec that is the only thing ever re-resolved.
3Three rules, none written down
Restated: the system has two files and at least three different, undocumented, per-field rules about which one wins on resume. agent is just the instance that got reported. Verified on upstream/main:
| Field | Who wins on resume | Mechanism | Runtime mutator that writes only base_state |
|---|---|---|---|
agent | meta | ConversationState.create() overwrites state.agent = agent (state.py:545), rebuilt from stored.agent (event_service.py:974-976) | switch_llm → #4032 |
workspace, max_iterations | meta | state.py:546-547 | — |
confirmation_policy | meta | server re-asserts after create (event_service.py:1059) | POST .../confirmation_policy → latent #4032 |
security_analyzer | meta | server re-asserts after create (event_service.py:1060) | POST .../security_analyzer → latent #4032 |
stuck_detection, hook_config, tags | meta | passed into the LocalConversation(...) ctor on resume | — |
acp_model | meta | rebuilt from stored.agent, kept in step by a hand-rolled mirror (event_service.py:1636-1639) | switch_acp_model |
stats, execution_status, agent_state, … | base_state | loaded and never overwritten | — |
#4032 already exists twice more, unreported. set_confirmation_policy and set_security_analyzer mutate live state but never call save_meta(), and both are re-asserted from meta on resume. Change either mid-conversation, restart, and it silently reverts to the creation-time value. Fixing agent alone leaves two live instances and an unbounded supply of future ones — because nothing in the code states the rule.
4The measured partition
The destination comes from set arithmetic over three Pydantic models, not hand-labeling. meta.json is StoredConversation(StartConversationRequest) — the server's create-request re-persisted. (StartConversationRequest lives in the SDK only so client and server share one schema; the SDK's own constructors take plain args and never consume a request object.) So: meta = persisted request, base_state = persisted Conversation. Diffing the classes gives an exact three-way split — which I re-derived independently of the study, and which reproduces it exactly:
| Bucket | Definition | n | Fields |
|---|---|---|---|
| 1 — the bug surface | persisted in meta ∧ on ConversationState | 9 | agent, workspace, max_iterations, stuck_detection, confirmation_policy, security_analyzer, hook_config, tags, id |
| 2 — create inputs the SDK consumes but doesn't round-trip | persisted in meta, ∉ ConversationState, ∉ bucket 3 | 18 | plugins, secrets, secrets_encrypted, client_tools, tool_module_qualnames, agent_definitions, agent_settings, agent_launch_additions, parent_conversation_id, conversation_id, initial_message, worktree, autotitle, title_llm_profile, observability_* (×3), user_id |
| 3 — genuinely server-added | StoredConversation − StartConversationRequest | 9 | id, title, created_at, updated_at, metrics, forked_from_conversation_id, forked_from_event_id, launched_agent_profile, required_runtime_credential_bindings |
Two details the arithmetic settles cleanly: agent_profile_id is the only exclude=True field in the whole pair — so agent_settings is genuinely persisted (bucket 2), and the study was right to correct agent_settings/parent_conversation_id from "server-only" to SDK request fields. And bucket 1 is 9 including id; the eight that actually matter are the rest.
Keep bucket 2, delete bucket 1. Bucket 2 is the declarative side of §2.3 — the SDK transforms these at construction (plugins → merged into the agent; secrets → secret_registry) and doesn't keep the raw input in state, so the server legitimately holds them to reconstruct the conversation. Bucket 1 is pure duplication: base_state.json already owns every field, and meta re-storing it is the bug.
5The rule, and its one exception
Stated once, mechanically checkable — with the exception named so it stays honest:
If a field is on ConversationState, it must not be authoritative in meta.json.
If the catalog needs it cold, it may exist there only as a signature-gated derived copy: one-way, rebuildable, memory-wins-when-live, never read back into the conversation.
The second clause is why §2.2 matters: the catalog genuinely must answer sort/filter/count without loading state. And the exception is not a loophole I'm inventing to dodge the hard cases — it already exists and is already implemented correctly. execution_status is a ConversationState-only field the server caches in _ConversationRecord and refreshes through _refresh_execution_statuses(): gated on an os.stat signature, invalidated to None the moment the conversation goes live ("Autosave will invalidate any signature we hold"), and explicitly memory-wins on the race ("Went live while we were reading disk; memory wins").
That is the shape any derived index copy must take — and the reason the rule stays enforceable. A derived copy lives in _ConversationRecord, not in StoredConversation, so the guard test (§7), which checks StoredConversation against ConversationState, still fires on any real duplication.
The two cold-read wrinkles, re-judged
tags— clean, delete it. I'd assumed the catalog filters on tags; it doesn't._search_conversationstakes onlypage_id,limit,execution_status,sort_order— there is no tag filter in the router today. Sotagsleavesmeta.jsonwith no index consequence at all. If a filter is ever added, it takes theexecution_statustreatment, not a resurrected authoritative copy.workspace— genuine, and my first answer was wrong. The server needs it beforeLocalConversationexists:start()readsstored.workspace,mkdirs the working dir and calls_ensure_workspace_is_git_repo(). I first wrote "the resume path can readbase_state.jsonfirst" — which is precisely the reasoning #4201 spent a PR removing. Better: leaveworkspacein meta as launch-spec input and delete it fromConversationStateinstead.create()already overwrites it from the caller every time, so base_state's copy has never once been authoritative. It is the one bucket-1 field where meta is the more defensible owner. Decide it explicitly in Phase 2.
6The phases
Each phase is independently shippable and revertable. The full sequencing table and the compact guard live in the study's §12; below is what a phase actually touches.
Phase 0 — Unblock #4032 now
kripper is blocked and has confirmed #4028 fixes it in his environment. Ship this week; don't hold the bug hostage to the redesign. Recommendation: land #4219's read-side approach, extended to ACP — take llm+condenser from base_state on resume, and delete the ACP mirror rather than adding a sibling to it (Q1 in §8 shows that's safe). #4028 adds a second hand-rolled mirror that Phase 1 then removes; #4219 leans toward the real fix. Either is acceptable — but do not merge both. Two overlapping fixes in one file pair is how the next inconsistency is born.
Phase 1 — agent out of meta.json
The whole point, and the blast radius is small: only six production sites read stored.agent, and §2.1 means the API layer isn't one of them.
- Add a discriminator to
StoredConversationagent_kind: str+acp_server: str | None, populated at creation — the one thing the server needs to know about the agent before state loads. - Replace the cold codex checks
_is_codex_agent(stored.agent)at three sites → read the discriminator. - Resume reads the agent from
base_state.jsonDrop thetype(stored.agent)/model_validaterebuild atevent_service.py:974-976. A brand-new conversation still gets its agent from the start request — written once, to one file. - ForkAlready copies
fork_conv.agent(the live agent, in base_state); just drop"agent"fromfork_overrides. - Stop persisting itMark
agentexclude=TrueonStoredConversation, mirroring theagent_profile_idprecedent. Old files keep anagent; readers ignore it, the nextsave_meta()drops it. Never write it again.
The two Phase-1 traps — both pass on old fixtures and break later, so name them in the PR:
(1) The one real SDK change is making ConversationState.create() take agent: AgentBase | None — None on resume means "keep the persisted agent, skip the overwrite and verify." This is the same change bucket-1's workspace/max_iterations need, so do it once, generalised, and Phase 2 becomes mechanical.
(2) StoredConversation.agent is typed AgentBase but defaults to None (Field(default=cast(AgentBase, None))). A new-style meta.json validates fine, and every remaining unguarded type(stored.agent) then fails later with a confusing NoneType error instead of a clear one. Branch explicitly on the resume path; don't lean on the default.
Phase 2 — the rest of bucket 1 (8 of 9)
Where the arithmetic changed the shape of the work — no longer a tidy-up, the actual fix.
confirmation_policy+security_analyzer— two live bugs, not hygiene. Remove the re-assertion atevent_service.py:1059-1060on the resume path (keep it for fresh creation). These are §3's unreported #4032 instances; ship them with a regression test each and say they were reproducible before the change. That turns "refactor" into "bug fix" for a reviewer.max_iterations,workspace— via the generalisedcreate(agent=None, ...)change;workspacecarries the §5 wrinkle.stuck_detection,hook_config,tags— stop passing them fromstoredinto the resume-path ctor; let loaded state supply them.idstays in both — shared key, not a duplicated value. The guard test whitelists exactly this one field and no more.- Write the §5 rule into a docstring on
StoredConversationandConversationState. The rule never being written down is why this recurred.
Phase 2b — reconcile the two secret shapes
meta's secrets/secrets_encrypted (sources, bucket 2) vs base_state's secret_registry (resolved). Related, not identical, so the arithmetic doesn't flag them — but they are two descriptions of one credential, the same disease in a different shape. Keep it separate from Phase 2 so the mechanical deletions land without waiting on the credential-model decision (#15298).
Phase 3 — make the single copy safe
Phase 1 makes base_state.json the sole home of the agent; §2.4 says that file bakes resolved secret values into mcp_config. Persist mcp_config in its unexpanded form (${...} intact) and expand at use time only. Then a rotated secret is picked up on the next resume instead of frozen, and the trajectory export has nothing to redact — the #4217 bandaid becomes unnecessary rather than load-bearing. This is #15298's invariant applied to one concrete field, and the step that earns "once and for all": Phases 1–2 make the config correct; Phase 3 makes the single copy defensible.
Phase 4 — Option B / single file
Cut. #4201 already built the derived, signature-gated, rebuildable index that Option B would need — and better than a relabel would have. A literal single file is off the table per §5. The only survivor worth keeping is a docstring on _ConversationRecord: this is a derived cache, never a source of truth — folded into Phase 2.
Guardrail for every phase: do not regress #4201 / #4100 / #4202 — sort, filter, count, paginate must stay answerable from the in-memory catalog without loading ConversationState. And agent-canvas needs no changes (§2.1). Both are worth an explicit line in each PR description, because "will this break the client / the list view?" is the question that otherwise stalls review.
7The test that defines "done"
Two tests. The first proves the behaviour; the second holds the line so no future PR can quietly re-add a duplicate.
(a) Round-trip, parametrized over every mutable field — create a conversation, mutate the field at runtime through its real endpoint, tear the service down, start a fresh one over the same directory, hydrate through the real resume path (get_event_service(), not the _event_services dict — #4100's lazy hydration means the direct read skips the resume path; this is the trap #4028 hit on rebase), and assert the live conversation observes the mutated value.
(b) The structural guard — the §5 rule as an assertion over the type system's own metadata, so it needs no repro to fire:
# fails the moment anyone re-adds a duplicated field
stored_persisted = StoredConversation.model_fields.keys() - EXCLUDED
overlap = stored_persisted & ConversationState.model_fields.keys()
assert overlap == {"id"}, f"meta.json re-persists ConversationState fields: {overlap - {'id'}}"
Land (b) first, xfail-ed with today's 9-field overlap listed, and let each phase shrink the expected set. Note EXCLUDED must subtract exclude=True fields — exclude=True suppresses serialization, not validation, so an excluded field is still readable in memory during the migration window and would otherwise let the guard pass while the field is still live. This is the whole point of switching to set arithmetic: "one source of truth" becomes a property CI enforces.
8Open questions, resolved
Two of the three that gated Phase 1 are settled; the study's §11 carries the full Q2 treatment. Summarised here so this plan stands on its own:
| Question | Answer |
|---|---|
Q1 — is ACP durable in base_state? | Yes, fully. acp_model/acp_server are regular Fields on ACPAgent (not PrivateAttrs), so they already ride inside state.agent → base_state. switch_acp_model already sets self._state.agent = new_agent (via model_copy, precisely so autosave fires) and writes agent_state["acp_current_model_id"] — both persisted. So the server's ACP mirror (event_service.py:1636-1639) is pure redundancy that exists only because start() rebuilds from stored.agent. Phase 0 deletes it in the same step — no grace period. |
| Q2 — should a restore re-read the changed profile? | Yes in intent — but as a revision-keyed 3-way merge, not a re-read. A blind re-read reintroduces #4032 the other way round (runtime switch_llm writes base_state only and never flows back to the profile, so a re-read would clobber the runtime switch). Merge on AgentProfile.revision: profile-changed-∧-runtime-didn't → take profile; runtime-changed → runtime wins. The hooks exist (LaunchedAgentProfile + deriveSwitchPlan #3720, monotonic revision, POST /{id}/switch_profile). Ship tier 2 (detect + surface "re-apply") now; decide tier 3 (auto-merge, needs a launch-time snapshot) in writing. Orthogonal to the duplication fix — must not block Phase 0/1. |
Q3 — does anything outside this repo read meta.json directly? | The one still worth checking (deployment tooling, migration scripts, runtime-api). The API is safe per §2.1; direct file readers would not be. |
9Sources
- Companion study — Two files, one truthWhere the copy is made, how it broke (#4032), the field inventory, and the compact §12 build plan this page expands.
- issue #4032The bug report: LLM profile timeout reset after agent-server restart.
- PR #4028 / PR #4219The two bandaids — mirror-on-write vs read-from-base_state. Both hand-scope to
llm+condenser: the type confusion of §2.3 showing through. - PR #4201Indexes execution status for search/count — replaces a full-state load with a signature-gated raw enum read. The concrete cost of loading state for listing (§2.2).
- PR #4100 / PR #4202Lazy hydration + idle eviction — the codebase investing in less state loading, which is why Option B is cut.
- PR #4217 / issue #4216Trajectory secret-leak fix (export-boundary redaction) — a bandaid Phase 3 makes unnecessary.
- OpenHands #15298Reference-only credentials — the endgame that makes even one copy safe (Phase 2b / Phase 3).