Lawrence engineering · Plan · July 2026

Editing in waves

Today Lawrence writes a document edit as one batch of up to 50 commands: the lawyer stares at a blank editor for 20 to 60 seconds, then every change lands at once, and the more interrelated edits are in that batch, the more of them miss. This plan moves editing to small sequenced waves: the document fills in front of the lawyer within seconds, and each wave is grounded against the live document so the agent's edits land where they should.

Draft for discussion 2026-07-01

previews / architecture / editing in waves

The plan in one paragraph

Keep the edit DSL and the single-transaction apply, but stop asking the model to pack one giant batch. Have it emit small, sequenced edit calls (waves), each grounded against the current document. Move the fragile bookkeeping the model does today (carrying the version token, re-finding text, keeping block ids straight) into the server, so reliability stops depending on the LLM getting concurrency right. Make waves cheap and visible with two changes we can verify are low-risk: stable block ids (an already-installed extension) plus having the edit endpoint return a fresh block map so a wave can chain to the next without a re-read; and notify-then-pull over the Ably channel chat already uses, so waves appear the instant they land instead of on a 1-second poll.

Stable block ids@tiptap/extension-unique-id on all block types
Apply returns a block maptouched blocks come back with fresh anchors
Server owns threadingversion token + re-anchoring leave the LLM
Waves, not batchesreverse the guidance; raise the iteration cap
Notify-then-pullreuse chat's Ably; kill idle poll cost

This is a plan to argue with, not a spec. Every mechanism below is grounded in the current code and the trade-offs are laid out on purpose. Mark it up: the open decisions flagged throughout are the ones most worth pressure-testing before we commit.

It builds on two companion write-ups: the content-service field guide (how documents are stored as Yjs/ProseMirror and served) and the edit & create DSL (the command grammar and the planner). If a term here is unfamiliar, those explain it.

The problem

The problem, measured

When Lawrence drafts or reworks a document, it emits a single edit tool call carrying up to 50 commands (the guidance today literally tells it to pack ~30 to 50 per call to avoid round-trip latency). These runs take anywhere from roughly 20 to 60+ seconds (from Langfuse), and the part that matters is where that time goes:

StepShare of the waitWhat happens
Model decides to editbrieffirst tool-input event fires
Model streams the whole command batchthe vast majoritythe args stream token by token — this is the wait
POST to lawrence-apinegligibleone HTTP call, full payload
Apply the batch (one transaction)smallsingle ProseMirror transaction, one Yjs update
Editor renders cardsnegligibleevery card appears at once

The wait is the model streaming the batch, not the server applying it. So any fix that only touches the apply path reclaims the small apply step, not the long streaming wait.

The batch is flawed on two independent axes, which is why fixing it is worth doing properly:

The obvious framing is a progress-UX problem. It is that, but the deeper prize is reliability: the same restructuring that shows progress also gives the agent the feedback and fresh grounding it needs to target correctly.

See it

Batch vs waves, side by side

Press play. Each side is the lawyer's review rail filling with real tracked-change suggestions (author, a bold change label, and neutral accept/reject). Left is today: the rail stays empty through a long silent edit, then every card drops at once. Right is the proposal: the rail fills in waves as the agent works. Same edits, same total work; the experience is the difference.

0%
Today · one batchone LLM call → all cards at once
Lawrence is editing…
Proposed · wavesseveral LLM calls, re-grounding between
Lawrence is editing…
first card — batch: after the whole batch waves: within seconds, then more striped bar = one LLM call · ↻ = re-ground against the live doc between calls

Schematic, not to scale — the shape, not the numbers. Production batch runs are roughly 20 to 60s+ (Langfuse). Cards = groups of tracked-change suggestions landing in the editor.

The shape

The backbone: waves plus server-side robustness

Two moves, and it matters that they are separate. One is about when the lawyer sees progress; the other is about whether the edits are correct. Conflating them is how the original ticket ended up scoped as UX-only.

Today

one batch, model-owned bookkeeping

  • Model packs ~30–50 commands into one edit call, planned against one read snapshot.
  • The model carries the version token and re-finds text; it must get concurrency right.
  • One transaction, one Yjs update, all cards at once only after the whole batch.
  • No feedback until the whole batch returns; interdependent edits mis-target.

Proposed

small waves, server-owned bookkeeping

  • Model emits small sequenced waves (a few related commands each), one per loop iteration.
  • The server owns version continuation and re-anchoring; the model stops doing concurrency plumbing.
  • Each wave is its own update and its own revision, landing as it completes.
  • The model sees each wave's results before composing the next; dependent edits get grounded.

Move 1 (waves) buys visibility and feedback. Move 2 (server-side robustness) is the one that actually raises reliability, and it is the through-line of the whole plan: every decision the system can make deterministically is one fewer decision the LLM can get wrong. This matters concretely: the version token the model carries today flips to null after the first edit, and the resolver rejects on strict mismatch — so a model-threaded token is a reliability dice-roll across a multi-wave run. Take it off the model's plate.

The mechanism

How one wave works

A wave is one small edit call, and the loop rides the agent's existing iteration structure: each iteration is one model completion plus its tool results, so a wave per iteration gives the model exactly one feedback point between waves. That is the reliability seam. It costs iterations, which is why the iteration cap has to rise (raised in the reliability model), but the cost is bounded — a big document rewrite is a handful of waves, not fifty.

1 · emit wave
model sends a few related commands with block-id + intent anchors.
2 · server grounds
resolver re-anchors against the live doc, applies one transaction, threads the version token forward.
3 · notify
Ably fires; the lawyer's editor pulls one diff; the wave's cards appear.
4 · feedback
the model gets this wave's results + a fresh block map, and composes the next wave against reality.

Steps 2 and 4 are the new work. The apply endpoint returns a fresh block map so step 4 needs no separate re-read (see "keeping the agent anchored").

Concretely, on a long advice letter: the model reads the document, redrafts the opening section (wave 1), and within seconds the lawyer sees those changes appear as tracked suggestions. While the lawyer glances at them, the model is already composing wave 2 against the freshly-returned state, and so on. Interdependent edits go in separate waves so each benefits from the prior wave's result; genuinely independent changes can ride together in one wave to keep the wave count (and iteration count) down. The lawyer can start accepting wave 1's cards while wave 3 is still being written.

The hypothesis, stated plainly

The bet is that reliability improves not because there's less work (the total edits are identical) but because the model (a) sees each wave's real result before composing the next, so interdependent edits stop compounding blind errors, and (b) never has to emit dozens of interdependent commands correctly in one unbroken generation. That requires multiple LLM calls, one wave per iteration, with feedback between them. A single call that merely chunked its own output would keep the blind up-front planning and gain little (which is why the concurrent-multi-call and in-tool-flush variants are rejected below). It is a hypothesis, not a given: splitting could instead just cost round-trips, or lose global coherence across waves. So the acceptance criteria are literally its test: if the apply-success-vs-batch-size curve doesn't flatten, the bet is wrong.

The honest cost

Waves trade total latency and token cost for perceived latency. Each wave is a fresh model completion over a growing message history, so total wall-time is comparable-to-somewhat-higher and tokens rise (prompt caching absorbs the stable system-prompt + document prefix; each wave's fresh results are new tokens). The win is that first progress appears within seconds instead of only after the whole batch completes. This is the central trade-off to accept or reject; the acceptance criteria below bound the total-latency downside so it can't run away.

Concurrency

What Yjs handles, and what it doesn't

Yjs covers part of the concurrency problem, and the half it doesn't cover is the half that matters here. Yjs handles the lawyer-typing case: a concurrent lawyer insert and the agent's edit both land with no lost data and every client converges. That works because by the time Yjs merges, the agent's edit has already been resolved from ProseMirror positions into character-level Yjs inserts/deletes bound to specific items, so the merge is by item identity, not by integer offset, and the two edits touch different items. The lawyer's own cursor even stays glued to its character through a remote change (that is Yjs relative positions, which the editor already uses for selections). Convergence and no-lost-updates are real guarantees we get for free.

What Yjs does not do is relocate a stale absolute position. A bare from/to computed against an old snapshot has no meaning after a concurrent insert shifts the content; merge operates on already-anchored ops, it does not re-interpret a number. And it gives no semantic conflict detection: two people editing the same sentence both "win" and interleave, which converges but can be nonsense for legal prose.

Why we are mostly safe today anyway: the resolver recomputes positions against a fresh read on every request and re-anchors by block id plus a normalized text find, so within a single request the positions are correct. Concurrency itself isn't defended by Yjs position semantics; it's defended by two coarse guards, the content_version_id equality check and the epoch on persist. The genuine hole: ordinary lawyer keystrokes append updates at the same epoch and don't reliably move content_version_id, so a lawyer edit that lands between the resolver's read and its write, changing the same block, is caught only by the per-block hash guard, and only if it is sent.

Decision

Yjs is a keep, not a lever: it protects data on merge, but it can't remove per-wave re-grounding because the agent never sees Yjs identity (it reads plain ProseMirror + block ids). So re-grounding stays the correctness floor. We harden the coarse guards: always send expected_block_hash and treat a mismatch as a hard re-read, and carry the read's lastAppliedUpdateId on persist so an intervening same-block edit is detected rather than silently overwritten. The fully principled fix, giving the agent Yjs relative-position handles so an edit survives concurrent change with no re-read, is real but a cross-repo protocol change (agents + lawrence-api + content-service); deferred as a future option, not part of this redesign.

Anchoring

Keeping the agent anchored, and cutting the re-reads

Cutting the re-reads is the highest-leverage change in the plan. In a multi-wave loop, each wave would otherwise need a fresh read to re-ground against the live document — a round-trip to lawrence-api mid-wave — so removing those re-reads is what makes waves cheap. They come from two kinds of drift, and we can kill both cheaply.

Structural drift is the cheap one to fix. Today only paragraphs and headings carry a stable id (the parser assigns a UUID on import); lists and any newly-created or list-wrapped block have none, so they resolve by a positional fallback (doc[index]:type) that goes stale the instant any earlier block is inserted or deleted. The fix is already installed: register @tiptap/extension-unique-id (the free MIT package, v3.15.3, not the old Pro one) across every block type and give the list nodes an id attribute. It assigns ids to new blocks, keeps the id on the correct half when a block splits, and it already knows to skip Yjs-sync transactions, there's a working precedent in the email-body editor to mirror. That removes the whole positional-fallback class of re-reads.

Content drift is the one that actually forces most re-reads: any text edit moves offsets and changes a block's hash, so the next command aimed nearby needs current text. The fix is to have the edit endpoint return a fresh block map, new block_id, from/to, and textHash for every block a wave touched. The resolver already holds the post-apply document and maps positions, so it can re-emit those anchors cheaply. Then a wave chains straight into the next with no separate read call. Stable ids are the prerequisite; apply-returns-a-block-map is what removes the re-read.

A · Stable block ids

@tiptap/extension-unique-id (installed)

  • content edit: stable
  • list wrap / structural: stable
  • new blocks: stable
  • DOCX round-trip: not durable

Ship now (+ apply-returns-map)

B · DOCX-durable ids

carry id through <w:pPr>

  • survives save/reload too
  • but: parser mints a fresh UUID on every import; generator never writes the id
  • needs a Word-safe carrier + parser read-back + round-trip tests

Defer — only if targeting across reload

C · Yjs anchors

relative-position handles

  • most durable across concurrency by construction
  • but: still not DOCX-durable, and a large rewrite of the read/DSL/resolver contract
  • couples the agent to CRDT internals the code deliberately hides

Defer — overlaps A at higher cost

Decision

Ship A (stable ids) + apply-returns-a-block-map. Together they remove most per-wave re-reads (structural drift entirely, content drift by returning fresh anchors), which is what makes waves cheap. Keep an explicit re-read as the fallback for the new-version and hash-mismatch cases. Defer B and C. Two refinements the plan folds in (detailed in “How others solve this”): the block-id anchor should be position-mapped with a fuzzy text find only as the fallback (find alone is unreliable on already-edited text), and ids only need to be stable within a load (a fresh Y.Doc is created per load), so DOCX-durable ids stay deferred.

Transport & collaboration

Is now the time to build real-time collaboration?

The last two sections were about getting each wave right; this one is about the lawyer seeing it. Visible progress is half of what Move 1 buys, and it only happens if the editor learns a wave has landed and pulls it in — the notify step in how one wave works. Today the editor syncs by a one-second REST poll, so making waves feel live without piling cost on the server is a transport question. And at its far end sits a larger one: whether now is the time to build real-time collaboration at all. The two are worth separating — start with the cost of what exists today.

The poll is cheap on the wire and expensive on the server — the opposite of the intuitive worry. The request carries only a Yjs state vector (tens of bytes, it doesn't grow with the document); the response is a small delta. But every one-second tick, for every open editor session, content-service does two Postgres reads and fully reconstructs the document (decode the whole snapshot into a Y.Doc, replay tail updates, re-encode, then diff), even when nothing changed. Fifty lawyers with a document open is fifty full reconstructions per second for zero edits.

It's tempting to think we could just emit to content-service instead of polling, but the two directions matter: the agent (and the lawyer's editor) already emit writes to content-service. The poll is the read direction, the editor pulling in others' writes. Removing it needs content-service to push. The good news: we don't need to build a transport — chat already runs a notify-then-pull channel on Ably we can mirror.

One axis ties the three options together: how much the server has to reconstruct. The poll reconstructs the whole document on every tick, even when nothing changed. Ably notify-then-pull removes that idle waste — it pulls only when an edit actually lands — though each pull still triggers one reconstruction; not optimal, but the idle cost was the real problem, and this fixes it with wiring that already exists. Hocuspocus removes the per-update reconstruction as well: updates are pushed incrementally over a WebSocket, so the server rebuilds a document only once, when it is first opened — and it unlocks live co-editing on top. The catch is that it's a standing new service that doesn't make the immediate goal any better for a single lawyer. That progression is why the plan proposes two stages: take the cheap idle-cost win now, and keep the reconstruction-free end-state as a deliberate follow-on.

Today
1-second REST poll
  1. agentcontent-svc — wave persisted as a Yjs update (append-only)
  2. editornothing is pushed; it waits for the next 1s tick
  3. editorcontent-svc — polls with a state vector (~tens of bytes)
  4. content-svcrebuilds the whole doc & diffs, every tick even when idle (2–3 DB reads)
  5. content-svceditor — returns an update delta carrying the agent's marks; the editor renders them as cards
Visible up to ~1s after it landsIdle cost full reconstruction / open doc / secondPresence none
Stage 1 · now
Ably notify-then-pull
  1. agentcontent-svc — wave persisted as a Yjs update
  2. content-svcAbly — publishes content.updated {id, lastUpdateId} (tiny, no content)
  3. Ablyeditor — pushes the notify (near-instant)
  4. editorcontent-svc — pulls once with a state vector
  5. content-svceditor — returns an update delta carrying the agent's marks; the editor renders them as cards
Visible ~instantly on notifyIdle cost none (slow safety poll as backstop)Still one reconstruction per notify; no presence
Stage 2 · follow-on
Hocuspocus (Yjs WS)
  1. agentWS server — wave enters the live Y.Doc as an incremental update
  2. WS serverall editors — broadcasts the same Yjs update (binary, no state vector)
  3. editorsapply locally; no reconstruction, no pull round-trip
  4. WS servercontent-svcpersists on a debounce, off the critical path
  5. editoreditorawareness carries multi-cursor presence
Visible sub-second, incrementalIdle cost ~zero (no reconstruction)Adds presence; needs a WS server + auth + persistence
The sequence after a single wave lands, and what travels on the wire at each hop. Stage 1 (this plan) swaps the poll's timer for an Ably push but keeps the same state-vector pull and server-side reconstruction; Stage 2 (a planned follow-on) replaces both with an incremental Yjs update pushed over a WebSocket, and adds presence. The agent's write is the same lawrence-api → content-service path in all three. Because the agent applies its edit server-side, the tracked-change “cards” are the lawyer editor's rendering of the agent's diffSuggestion marks once the update carrying them syncs into the local Y.Doc — the transport moves marks, not cards.
LayerWhatEffectCost / risk
1 · kill idle cost
ship first
Short-circuit reconstruction when there are no new updates, or add a light getContentYjsHead ({epoch, lastUpdateId}, one indexed read) that the client polls, pulling the heavy diff only when it advances.Removes the O(doc size) × N/sec CPU that is the actual problem.Small, isolated, no new infra.
2 · notify-then-pull
the redesign
In applyContentYjsUpdate, publish content.updated {contentId, lastUpdateId, by} to Ably alongside the existing event; mint a per-content token; the editor subscribes and pulls one diff on notify, with a slow ~15–30s safety poll as a backstop.Idle polling gone; steady-state cost near zero; waves appear the instant they land, not on the next 1s tick.Reuses chat's proven Ably wiring; adds channel volume to assess.
3 · Yjs WS providerAdd a real-time Yjs provider (Hocuspocus / y-websocket). We already use the Yjs CRDT; this adds the live transport we lack (and unlocks multi-cursor presence).Sub-second true co-editing.Stage 2 New standing infrastructure with no current driver — the real-time end-state, sequenced as a planned follow-on (see the decision below).

Layer 1 is worth doing regardless — a cheap, standalone server-cost fix, independent of any transport decision. Beyond it, the pragmatic shape is two stages, not a single either/or: Stage 1 is Layer 2 (notify-then-pull) now — it makes waves feel live and removes the idle-poll cost using wiring that already exists; Stage 2 is Layer 3 (Hocuspocus) as a planned follow-on, when a driver arrives. So the question isn't "poll or WebSocket forever" but "what do we build first, and what pulls Stage 2 forward." It should be chosen, not defaulted, especially since the real-time path was evaluated once already.

That path was weighed and set aside deliberately. Hocuspocus was built as a working reference (ooxml/examples/collab-editor, March 2026) before the poll, and the poll was chosen anyway with the rationale recorded at the time: "we don't need real-time collaboration right now" (FOX-942), and a commit noting it "is not true real-time, we can bring in web sockets later, but it is enough to ensure everyone is on the same page." So it was a conscious deferral for lack of a co-editing driver, not an oversight. What is not written down is a dated trigger for revisiting it.

The decisive variable, then and now, is editing topology: is the long-term model one lawyer plus the agent, or multiple humans co-editing one document at once? Almost everything else follows from that single answer, and today it's explicitly the former (no presence anywhere in the product; human multiplayer is out of scope).

Ably notify-then-pull

Stage 1 · reuse chat's realtime stack

  • + Reuses proven in-production wiring (server REST publish, subscribe-only tokens, notify-then-pull); no new server to run.
  • + Smallest change that meets the near-term need: waves appear instantly, idle-poll cost goes to ~zero, and the Yjs + epoch correctness substrate is untouched.
  • + Tiny content-independent messages; consolidates on the one realtime stack the team already operates.
  • No presence / multi-cursor and not true incremental co-editing. If topology ever goes multi-human, it can't satisfy it, and gets replaced.
  • Still two hops per edit (notify, then a REST diff that re-reconstructs server-side); needs new publish coalescing, connection multiplexing, and a document:<id> channel/token convention.
  • Ably plan limits at documents-scale are unknown from the repo; one open connection per mount today.

Hocuspocus (Yjs WS provider)

Stage 2 · the real-time end-state

  • + The only option that delivers true sub-second co-editing and presence / multi-cursor.
  • + Native incremental Yjs push, no idle reconstruction and no notify-then-REST round-trip; the best steady-state for a dense edit stream.
  • + Cleanest fit (the document is already a Yjs CRDT) and de-risked: a working reference for our exact stack already exists.
  • New standing infrastructure, a WebSocket server to run, scale and operate, plus new auth and content-service persistence integration.
  • No current driver: presence isn't a requirement, so today it buys co-editing nobody consumes (the spike's presence UX isn't even wired).
  • Adds a second realtime technology alongside Ably to operate and monitor.

Recommendation: a two-stage roadmap

For the requirements as they stand — single lawyer plus agent, no presence, no offline, near-instant wave visibility, kill idle cost — a two-stage path:

Stage 1 (this plan, now). Ship Layer 1 (kill idle reconstruction; it's transport-independent) and adopt Ably notify-then-pull for waves, reusing chat's wiring, with a document:<id> channel scheme, per-doc capability-scoped tokens, publish coalescing, and connection multiplexing to control the connection/channel cost.

Stage 2 (a planned follow-on). Productionize Hocuspocus as the real-time end-state — the ooxml reference is on the shelf — bringing incremental push (no per-notify reconstruction) and multi-cursor presence. It is not a better way to render the agent's tracked changes — Stage 1 already delivers those; it's a different capability class: true multi-party co-editing, live cursor presence, and the substrate for parallel sub-agents editing one document at once. Scoped as its own piece of work, not built here.

What pulls Stage 2 forward: a committed human co-editing / presence requirement (Ably-notify cannot satisfy presence), or measured wave density that makes the per-edit REST-diff reconstruction the dominant server cost (Hocuspocus's native push wins on efficiency alone, even without presence); or a move to parallel sub-agents editing one document concurrently, which wants a true multi-writer transport. Naming the trigger now means the follow-on is driven by a requirement, not rediscovery.

Sanity check

How others solve this

We're not the first to stream AI edits into a collaboratively-edited document, so it's worth checking the approach against how Tiptap, Notion, Google Docs, Cursor/Morph and the Yjs ecosystem do it. The spine holds up, and two things are worth changing.

Validated. Waves are mainstream: the field splits between apply-then-review (Tiptap AI, Notion AI, ChatGPT Canvas) and preview-then-apply (Google Docs suggestions), and both ship. Keeping AI edits in a separate tracked layer and merging/rebasing rather than mutating in place is the consensus concurrency model, and everyone rebases against stable block structure, so our stable-ids substrate is the right foundation.

Change 1: layer the anchor. We lean on block-id + text find, and prior art is blunt that search/replace targeting drops to roughly 40–45% accuracy once the target text has already been edited. So find should be the fallback, not the primary. The primary should be position-mapped, the block id plus a server-side position carried through tr.mapping, fed by apply-returns-a-block-map so each wave gets durable anchors instead of re-searching stale text, with a fuzzy find as the recovery path. (The fully durable version, Yjs relative-position handles the agent holds, is still the deferred cross-repo change from the concurrency section.)

Change 2: harden the apply, split locate from apply. Naive search/replace is the weak link. Cursor/Morph use a dedicated fast-apply pass; Moment.dev diffs the model's Markdown into ProseMirror steps rather than string-replacing. The portable lesson: separate "locate the target" from "apply the change" so a failed locate degrades gracefully (skip/park the command) rather than mis-applying, and prefer fuzzy matching or diff-to-steps over raw find/replace on edited text.

On transport, this plan departs from the field default. For live co-editing the ecosystem standard is a real-time Yjs provider (Yjs + Hocuspocus) giving incremental sync, presence, and clean three-way merge. We deliberately don't: reusing chat's Ably as notify-then-pull keeps the existing Yjs + REST-diff model and swaps the poll timer for a push — which is all a single-lawyer-plus-agent surface needs today. The full case, and the triggers that would make the real-time provider the right call, are in “is now the time to build real-time collaboration?”

Sources

Electric SQL, AI agents as CRDT peers with Yjs; Yjs relative positions; Moment.dev, Collab with AI is hard; plus Tiptap AI, Notion AI, Google Docs suggestions, and Cursor/Morph fast-apply. Accuracy figure is from the prior-art survey, not our own measurement.

The core bet

The reliability model: robustness over retries

It's tempting to make reliability "the model sees a failure and retries." That path is actively dangerous: the agent loop forces tools=[] on its final iteration, so a run that spirals on retries hits the cap mid-edit and gets cut to a text-only turn that can't finish the document. Retries competing with the iteration budget is a trap.

So the bet is the opposite: make each edit robust in the server, and reserve the model for genuinely semantic decisions. Most edit failures are mechanical, an occurrence counted wrong, a hash drifted, a block wrapped into a list, and the resolver can auto-repair those deterministically (re-resolve the find, re-anchor by the stable id, re-check the hash, thread the version token forward) with zero iteration cost. Only a genuinely ambiguous or semantically-wrong edit escalates back to the model, and that escalation is bounded. This is the same principle as moving version-token threading off the model: every decision the system makes deterministically is one fewer the model can get wrong.

Several supporting changes fall out of this:

The road not taken

Considered and rejected

In-tool incremental flush rejected

Parse the streaming tool-args and apply each command as it completes. Executes on partial un-validated JSON, shatters the atomic one-revision contract into racey sub-applies, bypasses the security override, and is invisible on the current transport anyway.

Concurrent multi-call waves rejected

Emit N edit calls in one turn (free on the budget). But they run concurrently against one snapshot (version/position races), give the model no feedback, and coalesce into one poll anyway. Sequential-across-iterations is the mechanism instead.

Per-command server flush only insufficient

Splitting the apply into N Yjs updates (a per-command server flush) reclaims only the brief apply window and gives the model no feedback. Folded in as a detail, not the backbone.

Yjs WS provider now deferred

Hocuspocus / y-websocket would give sub-second co-editing + presence, but it's a new transport with no current driver. Deferred to Stage 2 as a planned follow-on (see transport), not built in this plan.

Test harness in this feature parked

Full 250-request eval could become a distraction; parked to LEX-649 with design notes. Manual testing for now; the shared validator seam is designed so the harness can plug in later.

Model-owned token threading rejected

Trusting the LLM to carry content_version_id across waves (it flips to null after edit 1 and the gate is strict). Moved server-side.

Measuring it

How we'll know it works

Reliability without a metric is unfalsifiable, so the acceptance criteria are concrete even though we're testing manually to start. The metrics are mechanical and deterministic (no model-in-the-loop scoring): apply-success rate from the per-command status taxonomy (applied vs orphaned/ambiguous/overlap_conflict/ stale/failed), and document integrity (validates against the OOXML schema, round-trips to DOCX cleanly, no collateral change to untouched blocks).

Build order

Sequencing the work

Ordered so each step is independently shippable and de-risks the next. Steps 1–4 are the reliability + cost groundwork (valuable even without waves); 5–6 turn on waves and make them snappy.

None of these is a one-line change. The set spans three repos — ooxml, platform-v3, agents — step 3 reworks the concurrency-correctness core (and removes today's retry-based recovery), and step 5's real cost is validating the hypothesis, not the diff. Each row flags the repos it touches and a rough risk; expand it for where it lands, what changes, and what to watch — the summaries are the shape, not the full lift.

Phase A · groundwork — ships value even without waves

1 Stable block idsGive every block — lists included — a durable id so targeting stops falling back to position. ooxmlplatform-v3medium

Today. The OOXML parser stamps a UUID id on paragraphs and headings at import, but the list schemas carry none — bulletListAttrsSchema / orderedListAttrsSchema / listItemAttrsSchema hold only numId/ilvl. When a block has no id, computeBlockId falls back to doc[index]:type, which goes stale the instant any earlier block moves — the biggest source of avoidable re-reads.

Approach. Add id to the three list attr schemas (mirroring paragraphAttrsSchema), register bulletList/orderedList in the editor's UniqueID.configure (it already lists listItem), and extend the existing onEditorReady backfill to the new types — as a local (non-REMOTE_SYNC_ORIGIN) transaction so peers receive the ids. Server-side, add ul/ol to addNodeIdsToHtml.

Sketch

// attrs.ts — add id to the three list schemas
bulletListAttrsSchema  = z.object({ id: z.string().nullable(), numId: … })
orderedListAttrsSchema = z.object({ id: z.string().nullable(), start: …, … })
listItemAttrsSchema    = z.object({ id: z.string().nullable(), numId: …, ilvl: … })

// document-extensions.ts — register the new types (listItem already present)
UniqueID.configure({ types: [ …, "listItem", "bulletList", "orderedList" ] })

Build steps

  • Add id: z.string().nullable() to the bulletList / orderedList / listItem attr schemas.
  • Add bulletList/orderedList to UniqueID.configure in the document + email extensions.
  • Extend the onEditorReady backfill node set; add ul/ol to addNodeIdsToHtml.
  • Verify split-id behaviour with a runtime test (see below).
  • list attr schemas · ooxml/packages/schema/src/attrs.ts:88-102
  • UniqueID precedent + backfill · legal-os/…/emails/email-extensions.ts:44, …/consumers/use-document-editor.ts:68
  • computeBlockId · lawrence-api/…/resolvers/command-targeting.ts:7 — the doc[index]:type fallback this removes

Watch out. Split handling is the open question: UniqueID (3.15.3) has no keepOnSplit option and the ooxml schema sets none today, so a split may hand the new half a fresh id (or none). Needs a runtime split test; design anchoring to tolerate id reassignment. The backfill must not run under REMOTE_SYNC_ORIGIN, or peers never receive the ids.

2 Apply returns a block mapFresh anchors come back for every touched block, so a wave chains to the next with no re-read. platform-v3agentsmedium

Today. resolveMatterDocumentEdit returns MatterDocumentEditResult { path, content_version_id, results[], summary } — per-command statuses and diff-suggestion ids, no positions. applyPlansToYjsUpdate returns ApplyResult { updateBase64, appliedFlags, applyErrors, initialDocSize } and holds the post-apply state.doc (after state.apply(tr)) but discards the anchors, so the agent must re-read to learn where anything landed.

Approach. Define a BlockAnchor, extend ApplyResult and the result with an affected_blocks array, and after state.apply(tr) enumerate the touched top-level blocks (a block an applied plan wrote to) — the helpers already exist: enumerateTopLevelBlocks, computeBlockId, computeTextHash.

Sketch

// matter-document-edit-schemas.ts
type BlockAnchor = { blockId: string; textHash: string; from: number; to: number }

// yjs-apply.ts — after state = state.apply(tr)
for (const b of enumerateTopLevelBlocks(state.doc)) {
  if (touchedByAppliedPlan(b)) anchors.push({
    blockId:  computeBlockId(b.node, b.topLevelIndex),
    textHash: computeTextHash(b.node),
    from: b.offset, to: b.offset + b.node.nodeSize,
  })
}

Build steps

  • Define BlockAnchorSchema; extend ApplyResult + MatterDocumentEditResult (affected_blocks).
  • Derive anchors for touched blocks from the post-apply state.doc.
  • Thread through applyPlansAndUpdateResults; surface in the tool result the agent reads.
  • MatterDocumentEditResult / CommandResult · lawrence-api/…/schemas/matter-document-edit-schemas.ts:220-240
  • applyPlansToYjsUpdate / ApplyResult · …/resolvers/yjs-apply.ts:11,76 — post-apply state.doc
  • enumerateTopLevelBlocks / computeBlockId / computeTextHash · …/resolvers/command-targeting.ts:7-80

Watch out. tr.mapping is only live while the transaction is being built — derive anchors from the post-apply state.doc, not stale positions. Emit only for the appliedFlags that are true, and dedupe by blockId when several plans touch one block.

3 Server threading + hardened guardsMove version continuation and re-anchoring off the model; always re-check the block hash. platform-v3high

Today. decideStaleRetry compares agentVersionId vs refreshedVersionId (abort) else retries on the refreshed epoch. applyContentYjsUpdate({ contentId, updateBase64, batchId, expectedEpoch, identityId }) does the epoch CAS. The per-block expected_block_hash is validated only client-side in resolveCommandTarget; getContentState returns lastAppliedUpdateId but the resolver never threads it back.

Approach. Make the epoch the sole CAS and add a precise tail guard: thread lastAppliedUpdateId from fetchCurrentContentapplyYjsUpdate → the applyContentYjsUpdate transaction (check epoch and tail pointer), and return it in the result so the agent carries it forward instead of a version token.

Sketch

// applyContentYjsUpdate — CAS on epoch AND the tail pointer
if (content.currentEpoch !== expectedEpoch ||
    (guard !== undefined && snapshot.lastAppliedUpdateId !== guard))
  return { status: "stale", currentEpoch: content.currentEpoch }

// resolver deps — thread the tail pointer through
fetchCurrentContent(): { …, lastAppliedUpdateId: bigint | null }
applyYjsUpdate({ …, lastAppliedUpdateIdGuard?: bigint | null })

Build steps

  • Add lastAppliedUpdateId to fetchCurrentContent; add the guard to applyYjsUpdate + ApplyContentYjsUpdateInputSchema.
  • Check epoch + tail pointer inside the applyContentYjsUpdate $transaction.
  • Return lastAppliedUpdateId in the result; keep expected_block_hash as a persist-time guard.
  • decideStaleRetry · lawrence-api/…/resolvers/yjs-apply.ts:396
  • retry loop + resolver deps · …/resolvers/matter-document-edit-resolver.ts:23,149
  • applyContentYjsUpdate (epoch CAS) · content-service/…/services/applyContentYjsUpdate.ts:8
  • block-hash + lastAppliedUpdateId · …/command-targeting.ts:297, …/getContentState.ts:189

Watch out. This is the correctness core. Making the epoch the sole CAS means concurrent-edit recovery leans on the hash + tail guard — harden those before loosening the version-retry path. lastAppliedUpdateId is nullable (cold start): null === null must pass the CAS, not fail it.

4 Layer-1 idle-cost fixStop reconstructing the whole document on every idle poll; add a cheap head check. platform-v3low

Today. Every 1s poll runs getContentYjsgetContentState: content.findFirst + contentUpdate.findMany + applyUpdatesToSnapshot (decode → replay → re-encode) then Y.diffUpdate — every tick, even when the tail-update set is empty. Fifty open documents is fifty reconstructions a second for zero edits.

Approach. When the call carries a state vector, skip the reconstruction if there are no tail updates: have getContentState return the raw tail array (a returnTailUpdatesRaw flag); if it's empty the client is current, so return an empty update; otherwise diff. The @@index([contentId, lastAppliedUpdateId desc]) already backs a cheap head check.

Sketch

// getContentYjs — skip reconstruction when the client is already current
const s = await getContentState({ contentId, returnTailUpdatesRaw: !!stateVector })
if (stateVector && !s.tailUpdatesRaw)          // no tail updates -> already current
  return { updateBase64: EMPTY, epoch: s.epoch }
const snap = s.tailUpdatesRaw
  ? applyUpdatesToSnapshot(s.snapshot, s.tailUpdatesRaw) : s.snapshot
return { updateBase64: diff(snap, stateVector), epoch: s.epoch }

Build steps

  • Add returnTailUpdatesRaw to GetContentStateInput/Result; skip applyUpdatesToSnapshot when set.
  • In getContentYjs, return an empty update when a state vector is present and there are no tail updates.
  • Leave the no-state-vector (initial load) path returning the full snapshot unchanged.
  • getContentState (2 reads + reconstruct) · content-service/…/services/getContentState.ts:83-169
  • getContentYjs · …/services/getContentData.ts:45
  • head index · content-service/prisma/schema.prisma:186

Watch out. A version-scoped read already forbids a state vector (a zod refine), so the short-circuit only applies to latest-state pulls. Keep the empty-update path cheap — it's the common idle case.

Phase B · turn on waves

5 WavesOne small edit call per loop iteration instead of one packed batch; raise the iteration cap. agentsmedium

Today. The loop sets max_iterations = 10 (hardcoded); the terminal iteration (current_iteration >= max_iterations) forces tools=[]. The VFS guidance tells the model to “pack ~30–50 [commands]… to avoid round-trip latency,” so the whole document is planned blind in one turn.

Approach. Add max_iterations to ChatAgentConfig (default 10) and read it in the loop; rewrite the guidance from “pack 30–50 in one call” to “one small wave per iteration, re-grounding between.” The code change is small — the payoff rides on the hypothesis and the eval, not the diff.

Sketch

# config.py
class ChatAgentConfig(...):
    max_iterations: int = Field(default=10, ge=1, le=100)

# agent.py  (was: max_iterations = 10)
max_iterations = self.config.max_iterations

# matter_document_content.py — reverse the guidance
- pack ~30-50 commands per call to avoid round-trip latency
+ emit one small wave per iteration; re-ground on the returned block map

Build steps

  • Add max_iterations to ChatAgentConfig; replace the literal 10 in the loop.
  • Rewrite the pack-30–50 guidance to small sequenced waves.
  • Size the cap to the expected wave count + reads + a little slack.
  • max_iterations / must_terminate · agents/…/chat/agent/agent.py:1371,1378,1472
  • concurrent tool exec · agent.py:1574 (execute_concurrently=True)
  • pack ~30–50 guidance · agents/tools/platform-vfs/…/matter_document_content.py:184
  • ChatAgentConfig · agents/…/chat/agent/config.py:54

Watch out. Several tool calls in one turn are still one iteration with one feedback point, so concurrent multi-call “waves” buy nothing — the seam is across iterations. And the terminal tools=[] means a run that spirals hits the cap mid-edit; size the cap with margin.

6 Layer-2 Ably notify-then-pullPush a tiny notify on each wave so the lawyer sees it land instantly; idle poll gone. platform-v3medium

Today. applyContentYjsUpdate fires an Inngest content.updatePersisted on persist; nothing pushes to the editor. DocumentEditorSession sets a 1000ms setInterval → pollRemoteUpdates. Chat's getRealtimeToken already mints subscribe-only Ably tokens, and message.service publishes via ablyClient.channels.get(…).publish(…).

Approach. Publish content.updated to a content:<id> channel beside the Inngest send; add a content-service getDocumentRealtimeToken (subscribe-only, mirroring messaging); subscribe in the editor session and call pollRemoteUpdates() on notify; keep a slower poll as a backstop; unsubscribe in dispose().

Sketch

// content-service/applyContentYjsUpdate.ts — publish beside the Inngest send
ablyClient.channels.get(`content:${contentId}`)
  .publish("content.updated", { contentId, contentUpdateId, by: identityId })

// getDocumentRealtimeToken — subscribe-only, mirrors messaging
capability: { [`content:${contentId}`]: ["subscribe"] }

// document-editor-session.ts — notify triggers the existing pull
channel.subscribe(m => { if (m.name === "content.updated") this.pollRemoteUpdates() })

Build steps

  • Add an Ably client + publish beside the Inngest send in applyContentYjsUpdate.
  • Add getDocumentRealtimeToken (subscribe-only capability) in content-service.
  • Subscribe in the session constructor → pollRemoteUpdates on notify; unsubscribe in dispose; keep a fallback poll.
  • Inngest send (publish here too) · content-service/…/services/applyContentYjsUpdate.ts:76
  • Ably publish + token pattern · messaging-service/…/message.service.ts:334, …/realtime.service.ts:33
  • poll setup / pollRemoteUpdates / dispose · legal-os/…/document-editor-session.ts:19,111,248

Watch out. pollRemoteUpdates already guards with isPolling and handles epoch mismatch, so a notify-triggered pull is safe. The open cost question is Ably at document scale — channel and connection volume beyond chat's footprint.

Before we commit

Risks & open questions

Several of these are settled by the current code; here is the honest split.

Resolved (grounded in the current code)

Genuinely open (need runtime or production data)

Two refinements are part of the plan: the anchor is layered (position-mapped primary, fuzzy find fallback), and the apply splits locate from apply so a failed locate parks the command instead of mis-applying.

Copied to clipboard