#241

Sync server truth (2/3): every client-visible write is an event

Opencalledforthwants to merge intofrom

Part 2 of 3 of the sync "server truth" phase (F3). Merge it together with parts 1 and 3, and ship them with one server release. All three share protocol v17, and the web app deploys from main, so merging this alone would break the web app against an older server.

What changes for the user

  • Renaming a chat no longer moves it in the sidebar. A rename, a generated title, the agent's own name for its session and the first-prompt title all leave the chat and its project where they were. Real activity (a new chat, a turn, a question) still moves both.
  • Every device sees the same order. The server now announces each change to a chat's place in the list, and each change to a project's "last active" time. Before, the server moved them silently in several places (a title, the moment a new chat's agent finished starting, a second question) and each browser guessed.
  • A new chat shows the right agent and time on every device at once. The announcement of a new chat now carries the whole row, so a second browser no longer falls back to the OpenCode icon until it reloads.
  • Deleting a chat removes its sub-agent chats everywhere, reliably. The server announces each deleted chat, sub-agents first, and each draft that goes with them. Before, only the parent was announced and every browser had to work out the rest.
  • Removing a project keeps what you typed for it. Its new-chat drafts are kept without a project, as before, but now the server says so instead of each browser doing it.
  • Projects, composer choices and model lists survive a crash mid-write. Each of those used to be written in two steps (the row, then the announcement). A crash in between left devices out of step until a reload. Each is now one step.

How

The design is in docs/sync-foundations.md (§4.4, §4.7.1, §4.7.2, §4.7.11).

One writer per row. Workspace, composer-preference and provider-profile rows are written only by the projection of their event, inside the event's transaction. The registry and the composer store read through the event connection and follow committed records. Workspace availability and git state are observed on command paths and stored (workspaces.git_json, migration 22), so the environment snapshot only reads. Composer preferences carry a revision.

Session rows.

  • session.created carries a full SessionSummary, built and checked against the row the projection wrote. Old stored events still replay.
  • The runtime stamp writes only the provider's session id. A sub-agent's identity is stored by the same write that creates its row.
  • No title write moves updated_at. session.updated gains updatedAt, and the per-transaction status pass states it whenever it moved.

Derived workspace recency. In the same pass, for each workspace whose sessions or row the transaction touched, the repository emits one workspace.updated built from the rows when lastActivityAt changed, backwards included (deleting the newest chat lowers it). It has a deterministic id, so a retried write re-publishes it with its cursor.

Cascades as events (db/deletions.ts). A deletion emits draft.deleted and session.deleted for every session under it, deepest first, in one transaction. A workspace removal does the same for its sessions, emits draft.saved {workspaceId: null} for each kept draft, then workspace.removed, whose projection deletes the row. The projections refuse a deletion that would still leave a foreign key to remove a visible row silently.

Producer guards are assertions. A title the provenance rule refuses, or a roster or composer selection for a missing session, used to be dropped silently while its event went out. The projection now asserts changes === 1. The producers already decide (and the composer service now checks that the session still has its row).

The browser decides less. The shared client no longer walks a deleted session's children, no longer deletes a removed project's sessions or rewrites its saved drafts, and no longer stamps times from event timestamps or mirrors workspace recency. The mock environment emits what the server emits. Desktop runs on Convex, which announces none of this, so it keeps those rules as its own copy (§3.5).

Convergence harness

  • Flipped to guards: S8 and S8 (child) (the runtime stamp), X2 (session.created carries the row), X1 (a rename moves nothing).
  • New guards: S9r (a cascade inside the replay window replays one session.deleted per session, the child first); W1–W7 crash windows, 16 tests: register, rename, availability, last use, removal, composer preference, provider profile and a sub-agent's creation, each killed just before and just after its commit. The event is in the log exactly when the commit passed, the row agrees, and a client holding its bookmark converges.
  • Kept their causes: S4, S5, S6, S7, S10, S14 and S15 (later phases). S9 keeps its cause (an expired-gap snapshot never removes sessions); renames no longer move the catalog, so its construction now moves it with turns, and its X1 allowance is gone. S17, S18 and S19 belong to part 3; S19's final state now converges, and the open that resolves early remains.
  • The seeded runs' fingerprint list is empty: the four causes it held are fixed and deleted.

Runs

  • Convergence fast set, once: 60 passed, 7 nightly-only skipped, 1 todo. Every known divergence showed its documented cause and nothing else.
  • Server: 912 tests; 902 passed in the full run. The two turn-closure failures were expectations for the new recency record (fixed and green). cli, tunnel and workspace-icons failed only under full-suite load and pass alone. lifecycle failed under load and passes alone (4/4), as on part 1.
  • Desktop: typecheck clean, 664 tests passed.
  • Environment-client: 261 passed. Protocol: 269 passed, with the event-format digest updated.
  • Web (Node 24): 353 of 354 in the full run; the provider-health staleness test fails only under load and passes alone.
  • Typecheck across all packages, and lint on the changed code.

What reviewers should look at

  • apps/server/src/db/event-repository.ts: the status pass now compares and states updatedAt, and derives workspace recency. Derived records keep their source's delivery timing in server.ts (a derived workspace.updated must not pull a turn's events ahead of the turn.send answer).
  • apps/server/src/db/deletions.ts and the refusals in event-projection.ts: cascade order, and drafts detached with a new revision by no client.
  • The changes === 1 assertions in event-projection.ts. A refused write now rolls back its batch, which the batcher then retries first. That is right only while producers never emit what the projection refuses; the tests cover every pair of stored and incoming title source.
  • packages/environment-client/src/state.ts and mock.ts: what the client no longer does, and what the mock now emits.
  • apps/desktop/.../convex-environment-client.ts: the desktop's own copies of the removed client rules.

Known limits

  • Two-step create. A session's row is still announced before its provider session exists. A crash in between is part 3's S18.
  • Crash-window coverage. W7's root half (a session stamped after its creation) is part 3's.

🤖 Generated with Claude Code

Activity

12
cloudflare-workers-and-pages[bot]7d ago

🚀 Deploying Preview to Cloudflare 🚀

Preview Deployments by commit

StatusDeployment URLCommitUpdated (UTC)See this deployment's details
  • Build: Failed ❌

View logs ↗
38939112026-10-23T11:14:51.103ZView logs ↗
  • Build: Failed ❌

View logs ↗
5cce42b2026-10-23T09:07:58.374ZView logs ↗
calledforth7d ago