Lawrence engineering · Companion field guide · June 2026

Turning intent into transactions

Lawrence does not write Yjs, or even ProseMirror. It emits a small command language: "in this block, replace this text with that, and here's why." lawrence-api compiles a batch of those commands into a single ProseMirror transaction of tracked-change marks, then encodes it as one Yjs update and applies it with an epoch check. This is that DSL, and the planner behind it.

8 command types one ProseMirror tx tracked via diffSuggestion status: reference

previews / architecture / content & document services / edit + create DSL

The one-paragraph version

An edit is a JSON array of commands. Each command names a target (a block, a piece of text to find, an occurrence) and an intent (replace, insert, delete, format, change the block type, wrap a list). lawrence-api resolves each target to a ProseMirror position range, plans the change, and builds one ProseMirror transaction for the whole batch. Most commands land as a diffSuggestion mark, a tracked change the lawyer reviews, not a silent overwrite. The transaction is applied to the Yjs document, encoded as a delta update, and submitted through matter-service to content.applyContentYjsUpdate with an expectedEpoch guard and an idempotent batchId.

commandone intent + a target
targetingblock_id + find → PM positions
plannercommand → a ProseMirror op
diffSuggestionthe tracked-change mark
one transactionwhole batch, remapped positions
Yjs updateencoded delta + epoch CAS

The reason this is one tool call and not a free-text rewrite is trust. A lawyer must be able to see, and reject, exactly what the agent changed. So the DSL is built so that every edit is anchored to real text, lands as a reviewable suggestion, and fails loudly when the document has moved under it.

Demo first

The eight commands, interactively

The DSL is a discriminated union on type. Click through each command to see the JSON Lawrence emits, the ProseMirror operation the planner produces, and whether it lands as a tracked suggestion or a direct edit. The first six are tracked changes; the last two (set_block, list_op) apply directly.

1 · the command Lawrence emits


        

2 · the planner's ProseMirror op


      

how it's handled

All commands share a targeting core (block_id, find, occurrence, expected_block_hash) and a required explanation. Planners live in apis/lawrence-api/src/content/vfs/resolvers/planners/.

The grammar

The DSL reference

The whole grammar is a Zod discriminated union in matter-document-edit-schemas.ts. Every command mixes in a shared targeting core and a required explanation (the human-readable reason, shown to the lawyer and used for audit). Here is the union:

typeDistinguishing fieldsEffectLands as
editreplace: stringReplace the found rangetracked
insertposition: "before"|"after", replaceInsert text at a boundarytracked
delete(targeting only)Suggest removing the found rangetracked
formatmark: InlineMarkKind, op: "set"|"unset", attrs?Add/remove an inline marktracked
refinediff_suggestion_id, new_replace?, new_explanation?Mutate an existing Lawrence marktracked
withdrawdiff_suggestion_idRemove a Lawrence mark + undo ittracked
set_blockkind: BlockKind, attrs?, clear_inline_styles?Change block type/attributesdirect
list_opkind: ListOpKind, ordered_start?, ordered_type?Structural list changedirect

The shared targeting core

Mixed into edit, insert, delete, format, set_block and list_op (the id-addressed refine / withdraw skip it):

FieldZod typeRole
block_idstring().min(1).optional()Top-level block anchor from a DocMap read (e.g. doc[2]:paragraph)
expected_block_hashstring().min(1).optional()sha256 of the block's normalised text: a precondition that the block hasn't changed
findstring().min(1).optional()The text to locate within the block
context_before / context_afterstring().optional()Disambiguators when find is not unique
occurrencenumber().int().positive().default(1)Which match of find to use
explanationstring().min(1) (all commands)Why: surfaced to the lawyer, stored on the mark

The request envelope (MatterDocumentEditPayloadSchema) wraps the commands: content_version_id (nullish; the optimistic-concurrency token from the read), optional section_node_id (scope the edit to a sub-section), commands (1 to 50, MAX_EDIT_COMMANDS), and personId (the acting lawyer, force-injected server-side, see the pipeline section). The constrained sub-enums keep the agent inside the OOXML schema:

Where it comes from

A self-describing tool

Here is the thing that surprises people reading Lawrence's system prompt: the command grammar isn't in it. The prompt carries only the policy: edits land as tracked suggestions, the status codes are routing instructions to recover from rather than errors to escalate, read immediately before editing, batch up to 50 commands, use refine / withdraw on your own marks instead of stacking a competing edit. Then it points at the tool itself: "the runtime tool description carries the full payload schema, consult it before constructing a call." The DSL is self-describing.

The authoritative grammar is injected into the edit tool's own description at call time. edit.py's _build_tool_description pulls the per-surface documentation from MatterDocumentContentSurface.get_documentation(), the command payloads plus a STATUS HANDLING block, generated from the same definitions this page documents. So the model reads an up-to-date spec from the tool schema on every call, and it cannot drift from the code the way a hand-maintained prompt would. In practice Lawrence learns to edit from four layers, narrowing from general policy to the specific live signal:

system prompt
the rules: tracked suggestions, recover silently from statuses, read-before-edit, batch ≤50, refine/withdraw.
the tool description
the authoritative DSL grammar + STATUS HANDLING, injected at runtime from the surface docs.
the drafting skill
loaded on demand: create + edit as one workflow (placeholders, recovery, precedent-first).
live results
per-command status + reason_code + conflicting_mark: the turn-by-turn recovery signal.

The Zod schemas in matter-document-edit-schemas.ts are the single source of truth; the runtime description and the agents-side mirror are generated from the same surface definitions, so prompt and code can't diverge.

Why this matters if you're auditing the prompt

From the system prompt alone the edit DSL looks under-specified: a partial list of status codes and two field types (string, prosemirror), and no commands. That is expected, not a gap. The full contract lives in the tool description and the drafting skill. The prompt owns the policy (suggestions not overwrites, silent recovery, batching, never leaking internal ids to the lawyer); the tool owns the grammar. One thing the policy layer is worth sharpening on: it tells the agent to always call edits "proposed", but set_block and list_op apply directly, so a structural change isn't actually a reviewable suggestion.

Aiming

Targeting: how Lawrence points at text

Lawrence cannot send ProseMirror positions: it has never seen the document as a position tree, only as Markdown or a block map. So targeting is a translation problem. A command says "block doc[2]:paragraph, the 1st occurrence of £12,500", and the resolver turns that into a concrete {from, to} ProseMirror range. Three helpers do it, and all three must agree byte-for-byte with content-service:

block_id
doc[2]:paragraph → the top-level node. Not found → orphaned.
expected_block_hash
sha256 still matches? No → block_hash_mismatch.
find + occurrence
locate "£12,500" #1 in normalised text. Multiple blocks → ambiguous.
PM range
map back to from:40, to:47 for the transaction.

For insert the range is a zero-width point at the boundary; for set_block / list_op it spans the whole block.

A load-bearing cross-service contract

computeBlockId and computeTextHash in lawrence-api must byte-match the equivalents in content-service's ooxmlProsemirror.ts (the map builder). If they ever drift, the ids and hashes the agent reads won't resolve, and every command will come back orphaned or block_hash_mismatch. The same goes for two other constants: CONTENT_ROOT = "content" (the Yjs fragment name) and AGENT_AUTHOR_NAME = "Lawrence" (which marks count as the agent's). These are silent contracts spanning three files and two repos.

Suggestions

Tracked changes vs direct edits

The default is a tracked change. When Lawrence edits prose, the planner does not mutate the text in place: it wraps the change in a diffSuggestion mark, a ProseMirror mark carrying {id, originalText, suggestedText, author: "Lawrence", source, date, formatChange, comment}. In the editor the lawyer sees each as a green insertion or a red strikethrough attributed to "Lawrence" with a timestamp, and an accept/reject card in the margin; a batch of up to 50 commands lands as one revision, one card per command. A deletion is a diffSuggestion with an empty suggestedText; a format change carries a formatChange instead of text. Six of the eight commands work this way.

Two commands cannot, today. set_block and list_op change block structure (a paragraph becomes a heading, a paragraph becomes a list item), and the diffSuggestion mark only models inline deltas. There is no inline mark that means "this used to be a paragraph". So they apply directly, with diff_suggestion_id: null, and cannot be rejected the way an inline suggestion can. The tracked equivalent (an OOXML w:pPrChange) is tracked as future work in LEX-380.

Tracked · diffSuggestion

edit · insert · delete · format · refine · withdraw

  • Lands as a mark the lawyer reviews; nothing is silently overwritten.
  • Carries author: "Lawrence", so the system knows whose change it is. refine / withdraw are guarded to Lawrence-authored marks only.
  • Overlap is rejected: if the target range already has any diffSuggestion mark (any author), the command returns overlap_conflict rather than stacking changes.

Direct · no mark

set_block · list_op

  • Structural block changes applied via setNodeMarkup / list transforms.
  • diff_suggestion_id: null: not reversible through the reject-suggestion flow.
  • Deferred tracked equivalent: LEX-380 (block-property change tracking).

The machine

The pipeline: command to Yjs update

A whole batch of commands becomes one ProseMirror transaction and one Yjs update. The resolver fetches current state, gates on the version, plans every command, applies them to a single transaction (remapping positions as earlier edits shift later ones), writes the mutated doc back into the Yjs fragment, and ships the encoded delta. Step through it:

resolveMatterDocumentEdit documents://mat_123/matdoc_9/content

The single-transaction design means a burst of edits is one atomic CRDT update with one batchId. Per-command progressive flush is deferred (LEX-376).

Concurrency: two guards, one retry

There are two independent staleness checks. The version gate (content_version_id) catches "a new saved version exists since you read": every command short-circuits to stale. The epoch CAS at content-service catches "an update landed since you read": expectedEpoch must equal currentEpoch. On an epoch race the resolver re-fetches and retries once; if the re-fetched contentVersionId has moved it aborts instead (you'd be reapplying onto a different document). The batchId makes the whole thing idempotent: a duplicate returns exists, never a double-apply.

Outcomes

Result statuses and recovery

Every command returns its own result, so a batch can be partly applied. The status vocabulary is precise on purpose: each value tells the agent exactly how to recover. The whole point of failing with ambiguous or block_hash_mismatch rather than guessing is that a wrong edit to a legal document is worse than no edit.

statusWhen it's returned
appliedPlan built and the Yjs transaction applied successfully
ambiguousoccurrence beyond the match count, or find matches in multiple blocks
orphanedblock_id not found, find not found, or a zero-width / empty target
section_not_foundsection_node_id is not present in the document
block_hash_mismatchexpected_block_hash no longer matches the block's text (it changed under the agent)
overlap_conflictThe target range already carries a diffSuggestion mark (any author)
staleVersion gate failed, or an epoch race persisted after the one retry
target_not_foundrefine / withdraw: no mark with that diff_suggestion_id
not_authored_by_agentrefine / withdraw: the mark's author is not "Lawrence"
failedThe transaction threw, or attrs were invalid (e.g. a bad format value)
not_yet_implementedReserved in the enum (and the Python mirror) but never produced by any planner today

Recovery is the model's job, not the framework's. There is no automatic retry on the agents side: the per-command results are surfaced to Lawrence verbatim, and the tool description tells it what to do (on stale, re-read and re-issue with a fresh content_version_id; on overlap_conflict, refine or withdraw the existing mark instead). The only programmatic retry anywhere is the single epoch-race retry inside the lawrence-api resolver. This keeps the agent in the loop and auditable rather than silently hammering a document.

Starting fresh

The create path

Creating a document is simpler than editing one, because there's no concurrency to defend. create takes one of two strict shapes, and the generic /write verb is deliberately blocked for documents so that all changes flow through the reviewable edit path.

From ProseMirror JSON

{ name, personId, json }

  • The agent supplies a full ProseMirror document.
  • createMatterDocumentFromProsemirrorJson → content-service content.createContentFromProsemirrorJson (scope matter/<id>).
  • Used when Lawrence drafts something new from scratch.

From a precedent

{ name, personId, sourcePrecedentPath }

  • A precedents://<matter>/<templateId> path, scope-checked.
  • createMatterDocumentFromTemplate: a byte-for-byte copy of the published template version, no AI rewriting.
  • Supplying both json and sourcePrecedentPath fails the union.

personId (and actorIdentityId for task/keydate creates) is auto-injected from the agent's context on the Python side, so the LLM never chooses who it is acting as. Both create shapes return { path, id }.

Reality check

Gotchas & open questions

The honest edges of the system, so the map matches the territory:

not_yet_implemented reserved

In the status enum and the Python mirror, but no planner ever emits it. Held for future command types.

format op: toggle v1 gap

Only set / unset are supported; toggle is not.

list_op coverage v1 gap

No multi-paragraph wrap and no per-item sink/lift nesting yet.

set_block / list_op reject deferred

Direct edits, so they can't be rejected as suggestions. Tracked block-property change is LEX-380.

Per-command flush deferred

The whole batch ships as one Yjs update / one batchId. Progressive per-command flush is LEX-376.

replace marks limitation

Replaced text inherits only the leading character's marks; styling that varies across the span is lost.

newEpoch return latent drift

The resolver's dep type allows newEpoch?, but the apply path never returns it; the retry loop always re-fetches. Effectively unused.

block-id / hash parity contract

Must byte-match content-service ooxmlProsemirror.ts. Drift → every command orphaned / block_hash_mismatch.

Edit telemetry stubbed

The resolver's record* hooks are no-ops with stable metric names; no metrics emitted yet.

Agent-side retry by design

No automatic retry in the Python client; recovery is LLM-driven from the status. Only the resolver's one epoch-race retry is programmatic.

Copied to clipboard