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.
By Harry · Lawrence squad · grounded in platform-v3 + agents @ develop
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
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:
Step
Share of the wait
What happens
Model decides to edit
brief
first tool-input event fires
Model streams the whole command batch
the vast majority
the args stream token by token — this is the wait
POST to lawrence-api
negligible
one HTTP call, full payload
Apply the batch (one transaction)
small
single ProseMirror transaction, one Yjs update
Editor renders cards
negligible
every 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:
Latency and visibility. The lawyer sees nothing until the whole batch
lands. For a large document that is 20 to 60+ seconds of a blank editor with no signal whether
the agent is working, stuck, or failed. Visibility during the run is a product requirement, not
a nicety.
Reliability degrades with batch size. Every command targets the document by
block id and a text find, and the whole batch is planned against a single read
snapshot. Interdependent commands are the problem: command 7 targets a paragraph command 3
rewrote, but command 7 was planned blind against the original text, so it lands wrong or misses.
The more interrelated edits in a batch, the more independent chances to mis-target, with no
feedback loop to catch it until the whole batch returns.
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 batchwaves: within seconds, then morestriped 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
agent → content-svc — wave persisted as a Yjs update (append-only)
editor — nothing is pushed; it waits for the next 1s tick
editor → content-svc — polls with a state vector (~tens of bytes)
content-svc — rebuilds the whole doc & diffs, every tick even when idle (2–3 DB reads)
content-svc → editor — 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
agent → content-svc — wave persisted as a Yjs update
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.
Layer
What
Effect
Cost / 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 provider
Add 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 fuzzyfind 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?”
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:
Reverse the batching guidance. The documents tool description and the
drafting skill currently tell the model to pack ~30–50 commands per call. Flip that to
small sequenced waves. This is a prompt/skill change, but it must land with the loop and
threading, not before, or the result is more round-trips with none of the safety.
Promote max_iterations to config and raise it. It's a hardcoded
local (set for the one-batch world) today. Waves need headroom; make it tunable and size it to
the expected wave count (set by the dependent-vs-independent edit mix that Risks flags as still
open) plus reads plus a little retry slack.
content_version_id becomes a structural guard only. Treat it as
"a new saved version exists, re-anchor everything," not as concurrency defense for ordinary
edits (which it doesn't reliably provide). Same-block concurrency is covered by the hash +
lastAppliedUpdateId checks from the concurrency section.
Split locate from apply, and pick the review mode per wave. Separate finding
the target from applying the change so a failed locate parks the command instead of mis-applying
(raw find/replace is unreliable on edited text — see “how others solve this”); and let small, high-confidence
waves apply-then-review while large or structural ones preview-then-apply — both are shipped
patterns elsewhere.
The road not taken
Considered and rejected
In-tool incremental flushrejected
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 wavesrejected
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 onlyinsufficient
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 nowdeferred
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 featureparked
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 threadingrejected
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).
Primary: apply-success and integrity improve on interdependent / large-batch
tasks, and the success-vs-batch-size curve flattens where the batch path degrades.
Perceived latency: time-to-first-card drops from after-the-whole-batch to within seconds.
Guardrails (must not run away): total wall-time within an agreed ceiling of
the batch path; token cost within budget; no increase in invalid-document rate; the lawyer's
review burden (card count / false edits) doesn't balloon.
Method: baseline the current batch path first, then flag-gated A/B (batch vs
waves) with live metrics before any un-flagged rollout. Manual runs now; the automated harness
(LEX-649) plugs into the same validator seam when we want the full curve.
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
1Stable 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
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.
2Apply 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,
})
}
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.
3Server 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 fetchCurrentContent → applyYjsUpdate → 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.
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.
4Layer-1 idle-cost fixStop reconstructing the whole document on every idle poll; add a cheap head check.platform-v3low▸
Today. Every 1s poll runs getContentYjs → getContentState: 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.
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
5WavesOne 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.
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.
6Layer-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().
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)
Node names for UniqueID — blockquote and
codeBlock are stock TipTap names, not overridden, so UniqueID can be configured for
them directly.
The fetch→persist race — the only guard is the epoch, and the
epoch bumps on a new version, not on ordinary edits, so two same-block edits within a
version simply CRDT-merge (there is no lock). That coalescing is what waves rely on within a
load; the residual same-block hazard is closed by always sending expected_block_hash
and carrying lastAppliedUpdateId.
DOCX-durable ids aren't needed — a fresh Y.Doc is created per document
load (the session key embeds the epoch), so ids only need to be stable within a load.
Option B is off the table unless a flow must target the same block across a reload.
Apply-returns-a-block-map is feasible — the map is derivable from the
post-apply state.doc + tr.mapping via the existing buildMapBlock
helper; exposing it is a small additive change, not new machinery.
Genuinely open (need runtime or production data)
UniqueID across node splits. v3.15.3 correctly skips Yjs-sync fields but does
not set keepOnSplit, so a split may hand the new half a fresh id. Needs a
runtime split test; until then, design anchoring to tolerate id reassignment on split.
Dependent-vs-independent edit mix. Determines real wave counts and whether
the raised iteration cap ever binds. Measure from real document-edit traces.
Waves' total latency + token cost on real runs, to set the guardrail ceilings
honestly.
Ably at document scale. Chat runs on it, but documents add channel volume;
assess cost/capacity.
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.