Sync the Source of Truth, Rebuild the Rest on Each Stack — Cross-Stack AI Conversation Sync
A Shellby postmortem: syncing AI conversation history between Apple and Flutter stacks. A conversation has both raw messages and a step transcript, but only the raw messages should sync — the steps are derived, rebuilt on each stack from the messages. Plus a 'HarmonyOS receives nothing' version-field pit.
Shellby’s AI Agent conversation history needs to sync between the Apple (Swift) and Flutter stacks. It sounds like “move the chat records over,” but a conversation takes different shapes on the two ends, forcing out a principle worth writing down: sync the source of truth, and rebuild the derived parts on each stack.
A conversation has two copies of data
On the Flutter side a conversation holds two things:
- raw messages
history: [AIMessage]— the authoritative context fed to the model: text, tool calls, tool results, all of it; - a step transcript
steps— the human-facing UI view stream:cmd(what command ran),file(what file changed),q/a(asked the user / user answered),user/assistanttext.
And the Apple side only persists messages, doesn’t store steps at all — it rebuilds them from messages for display.
So “what to sync” has a trap. If you sync steps as independent data, the Apple side is always empty (it doesn’t store them), and syncing across loses the step-level UI. The right answer is to separate primary from derived: messages are the authoritative fact, steps are its derived form. The derived form doesn’t go into the sync channel as an independent fact; it’s computed on each end from messages.
Deriving: translate messages into steps at encode time
The approach is asymmetric yet symmetric: Apple derives steps from messages at encode time (using exactly the same mapping as the in-app AIAgentSession.reconstruct), outputting the step format Flutter expects; at import time Apple reads only history and rebuilds its own display. Send the derived form out for the other end to use, receive only the source of truth and rebuild yourself — the two directions are symmetric.
The derived mapping aligns tool by tool, with enum names consistent across stacks: run_command → cmd step, write_file → file step, ask_user → q(/a) step. The risk is computed on the fly — CommandClassifier.classify on the command, classifyWritePath on the path, with enums readOnly/mutating/destructive literally identical across stacks; the status comes from whether the corresponding tool result isError. Before deriving, scan messages once to build a toolUseID → (output, isError) map, then backfill each step.
case AgentToolName.runCommand:
guard let inp = try? JSONDecoder().decode(RunCommandInput.self, from: input) else { return [] }
return [.object([
("t", .string("cmd")),
("command", .string(inp.command)),
("rationale", .string(inp.rationale)),
("risk", .string(CommandClassifier.classify(inp.command).rawValue)), // computed
("status", .string(isErr ? "failed" : "ok")), // from result
("output", .string(out)),
])]
The benefit: the step transcript always matches the current classification rules. Change the risk rules someday, re-sync once, and old conversations’ step risk labels update along with them — because it isn’t a stored snapshot that goes stale, but a derivation computed fresh each time.
HarmonyOS receives nothing: a version-field gap
After the feature connected, an eerie thing appeared: HarmonyOS received none of the AI conversations synced from Apple.
The root cause was an over-eager forward-compatibility check. Flutter’s AgentTranscript.fromJson gates on version at the very first line:
// Missing v treated as v1 — cross-stack: Apple's encoder doesn't write a v field, and a strict v==1
// check would discard the entire conversation synced from Apple. Only an explicit unknown-higher version is rejected.
final v = json['v'];
if (v != null && v != 1) return null;
Before the change it was if (json['v'] != 1) return null — and the Apple encoder writes no v field at all, so json['v'] is null, not equal to 1, and the whole conversation is treated as “unknown version” and discarded. The fix loosens the semantics: a missing v is treated as v1, and only an explicit, unknown, higher version is rejected. Forward protection (guarding against a future breaking format) stays, but it no longer wrongly kills legitimate “no version written” data.
The same fix plugged a sibling pit for aiConfig: when applying remote config for the LWW (last-write-wins) comparison, if the json’s aiConfigUpdatedAt is missing or 0, fall back to the record’s top-level updatedAt — otherwise the remote config’s timestamp is always 0, always loses to local, and synced config never takes effect.
Two anti-drift details
Extension keys appended, without touching the frozen byte vector. The sync payload originally had 5 base collections (hosts / groups / identities / forwardRules / secrets), whose byte order was frozen into a deterministic test vector. When adding AI data, extension keys like aiConfig / aiChats are appended after the base keys, and non-alphabetically — so a payload without AI data has completely unchanged bytes, not breaking the frozen vector. The merge logic also changed from “hardcoded collection list” to “iterate the union of both stacks’ collection keys,” so extension collections auto-join the record-level merge and adding a new collection needs no merge code change.
No loop-bump when applying remote. When applying synced config, suppress the “config change → bump timestamp” logic. Otherwise “receive remote → local gets modified → timestamp updates → pushed back out” oscillates into a sync loop.
Takeaways
- Sync the source of truth, rebuild the derived on each stack. If one piece of data can be computed from another, don’t sync it as an independent fact — syncing it only creates holes or inconsistency on the end that doesn’t store it. You may send the derived form out for the other end to use, but on receipt take only the source of truth and recompute;
- Derived-following-rules beats a stored snapshot. The step’s risk label is computed fresh from the current classifier each time, so it auto-updates when the rules change and you re-sync; a stored snapshot would be frozen at the old rules;
- A forward-compatibility check must not wrongly kill “no version written.” A blanket
v != 1rejects “defaulted version” too; the correct rule is “missing = lowest version, reject only explicit unknown-higher.” Cross-stack, one end not writing it and another strictly checking it is the breeding ground for these ghosts; - An LWW timestamp missing needs a fallback, or the end whose “timestamp is always 0” loses forever and remote updates never land;
- Adding extension data mustn’t touch a frozen serialization. Append extension keys and serialize only the collections actually present, keeping old payloads’ bytes unchanged; merge iterating the union of keys rather than a hardcoded list — onboarding a new data type is adding data, not changing the protocol.
Comments