Skip to content

Staged sessions

The live client sends every change immediately. That’s right for direct manipulation, but wrong for work that should be reviewed before it lands — an AI agent drafting edits across several documents, a form with a save button, a batch refactoring of a diagram. The staged session adds a staging layer for exactly that.

A session wraps the live client. Through it you edit documents as usual, but changes are staged, not sent:

  • Reads through the session show the staged view: the live document — its head — kept current by ordinary sync, with your staged changes overlaid on top. You are never looking at a snapshot: as collaborators change the document underneath you, the overlaid read moves with them. (A skipStaged read bypasses the overlay when you want head exactly as it stands.)
  • Each staged document records the base sequence it was edited from, and every staged change carries guards asserting the values it was authored against.
  • Because head keeps moving, the session can tell you at any moment whether it has drifted somewhere that conflicts with your staged work.

When ready, commit() writes all staged changes atomically — all stages land or none do, and a commit is refused while any stage is blocked by a conflict. Or walk away: discard the session and nothing ever hits the live documents.

Rich text participates fully. The session forks the target Y.Doc into a bindable staged copy and hands you that, so a real editor (Tiptap, ProseMirror) drafts against the copy while the live document is untouched. At commit, the copy merges back into the target.

Here’s the datadata twist: give the session a host document and its state — the changeset — is persisted inside that document, in a dedicated field. Because the changeset is then just document data:

  • It survives reload. Reopen the session over the same host document and the staged work resumes.
  • It syncs. Two collaborators (or a human and an agent) each open a session over the same host document, and see and contribute to the same staged changeset, converging like any other concurrent document edit. Staged rich-text copies get live carets between participants for the same reason.
  • It’s inspectable through the synthesized sys:session and sys:stage documents, like everything else in datadata.

A natural host is the document that motivates the changes — for example, an AI conversation document hosting the changeset of edits the conversation is producing.

Persisting the changeset requires the session’s principal to hold write access to the host document — the staged work rides ordinary host-document writes. Often that’s fine: the human who owns the conversation may edit the conversation. But when the session’s editor shouldn’t control the host — an AI agent staging edits into the conversation that drives it must not rewrite that conversation, or read its own staging machinery back — the app narrows the session, not the client:

const session = client.openStagingSession({
hostType: "chatConversation",
hostDocId,
scopes: [
{ kinds: ["read", "create", "update", "rename"], excludeDocTypes: ["chatConversation"] },
],
});

excludeDocTypes is the scope’s deny axis — “everything this scope covers, except these types” — the shape an allow-list can’t express when the rest of the world is open-ended. With the hosting type excluded, the session treats every document of that type like any other out-of-scope document: it reads as absent, drops out of the sys:index / sys:trash listings, and every session-routed write targeting it (the staged Y.Doc lanes included) is rejected. The changeset persistence is untouched — it rides the client’s own capabilities underneath.

Two things to keep straight: scopes are OR-ed, so the exclusion must appear in every scope that would otherwise cover the type; and this is per-session app policy, not a property of the host — a reviewer’s session over the same host can keep the conversation readable by simply not excluding it.

The listings drop excluded documents rather than redacting them — ordinary hidden existence — so a type resolved through the session can’t distinguish hidden from nonexistent. An app gate that needs that distinction resolves through the client instead, whose listings this session-level attenuation doesn’t touch.

Excluding a docType does not fence off that type’s schema document. A schema document’s own type is sys:schema, not the type it defines, so an editor hidden from chatConversation documents can still redefine what a conversation is — and excluding sys:schema wholesale would take away schema authoring for every other type along with it. Name the document instead:

const session = client.openStagingSession({
hostType: "chatConversation",
hostDocId,
scopes: [
{
kinds: ["read", "create", "update", "rename"],
excludeDocTypes: ["chatConversation"],
// schemaDocId("chatConversation") spells this id for you
excludeDocIds: ["sys:schema:chatConversation"],
},
],
});

excludeDocIds is the same deny axis one grain finer — freeze these documents, whatever their type. An app hiding a host type usually wants its schema frozen too, so reach for both together. Two properties make it a dependable freeze: docIds are stable (rename changes a document’s display name, never its id, and schema documents refuse rename outright), and create is gated on the same id, so freezing an id that nothing has claimed yet blocks the document from being created at all.

Freezing is not blinding, though. The system documents — sys:index, sys:trash, sys:access, sys:principal, sys:schema:* — are exempt from read attenuation and stay readable however they’re scoped, because a client that couldn’t read the schema couldn’t validate or predict against the type. A frozen sys:schema:chatConversation means no writes, not invisibility.

The host document is optional. Open a session without one — no host schema, no field to declare — and the changeset is held in memory instead.

An in-memory session stages, previews conflicts and commits exactly like a hosted one — same reads, same guards, same atomic commit(). What it gives up is everything that followed from the changeset being document data: the staged work does not survive reload, does not reach other devices, and cannot be collaborated on. It is single-client by construction, so staged rich text has no carets to show. Dispose the session and the work goes with it.

That suits staged work that lives and dies inside one process — a server script that stages a batch of edits and commits them in one pass, a single agent turn handled server-side, a test. Reach for a host document as soon as the staged work must outlive the client that made it, or be seen by anyone else: a form with a save button wants one, otherwise a reloaded tab drops everything the user typed. Either way sys:session and sys:stage work, since those are derived from the changeset rather than from the host document.