Architecture · companion to the RCA · sibling: prompt edits

Should Lawrence emit ProseMirror, or grow the edit DSL?

The RCA's prompt-fix unblocks the easy stuff. The formatting failures need code. This note grounds the "what code" decision in the OOXML schema and the v3 editor surface.

Date · 2026-05-11 Editor schema · @lawhive/ooxml-extensions Live editor · v3 / legal-os Author · Adolfo

Option A · let the model emit ProseMirror

Same shape for create and edit. No DSL growth. Full expressive power. But: positions drift under concurrent edits; the "propose, don't mutate" wrapper still has to be re-added; per-command status codes collapse to a single boolean; verbose tokens.

vs

Option B · grow the DSL with named ProseMirror-mirroring variants

format + set_block + list_op. Same anchor-targeting model (block_id + find + block_hash). Tracked-suggestion semantics free. Per-command recovery preserved. Surface bounded by the editor's actual capability set.

Verdict — Option B, grounded in source. The editor's surface is small and the DSL pays its way as the anchor + suggestion + recovery layer.
12
toolbar capabilities today
3
new DSL variants needed
≥95%
of asks covered
~10
pragmatic upper bound

Toolbar count from features/document-editor/components/toolbar/editor-toolbar.tsx:85-440. Variant count from §5 below.

1. Why the DSL is load-bearing, not redundant indirection

Three things the DSL gives you that raw ProseMirror Steps don't:

Anchor-based targeting

Matter documents are collaborative — the lawyer is typing while the agent is reasoning. By the time the model emits a Step at position 1247, the lawyer's keystroke has shifted everything. The DSL targets by block_id + find + context_before/after + expected_block_hash. Survives drift. Position-based Steps don't.

matter-document-edit-schemas.ts:5-12 · TargetingCoreSchema

Tracked-suggestion wrapper

Every edit lands as a diffSuggestion mark the lawyer reviews. ProseMirror's native model is "mutate now". If the model emitted raw Steps, the server would still translate them into suggestions — add marks, compute inverse Steps for withdraw, handle overlap conflicts. That translation is where most of the value already lives.

yjs-apply.ts:101-172 · five diffSuggestion-only operations

Per-command recovery

Today: MatterDocumentEditPayloadSchema.safeParse(...) rejects unknown fields and emits per-command status codes — stale, orphaned, ambiguous, overlap_conflict, block_hash_mismatch. The agent recovers per-command. Raw ProseMirror would collapse this to "applied or not".

vfs-matter-document-adapter.ts:400-405

Validation surface (and footgun lock-in)

A named-command DSL is a finite enumeration. The model can't invent block_attrs and have it silently dropped — Zod rejects it. Raw ProseMirror lets the model construct malformed marks, invent node types, break OOXML round-trip silently. The DSL constrains the surface to "things the system supports right now".

matter-document-edit-schemas.ts:55-61

2. The editor's actual capability surface

Enumerated from source. ooxml/packages/schema/src/schema.ts:58-275 declares ~22 node types and 12 marks. The new-stack toolbar exposes 12 user-visible capabilities. Everything else is in the schema but not surfaced today.

covered by DSL today (text only) NOT covered today — agent confidently fails covered after proposed DSL growth in schema · not on toolbar · long-tail
Capability Editor op ProseMirror primitive DSL today DSL after
Diff-suggestion lifecycle · already covered
Insert / replace / delete textadd-mark · insert-text-with-mark · replacetr.addMark · tr.insertText · tr.deleteedit insert deleteunchanged
Refine your own suggestionrefine-markmutate mark attrsrefineunchanged
Withdraw your own suggestionwithdraw-markconditional insert/replace/deletewithdrawunchanged
Inline marks · failed in session
Bold / italic / underline / striketoggleBold/Italic/Underline/StrikeaddMark / removeMarknoneformat
Font familysetFontFamilysetMark textStyle.fontFamilynoneformat
Font sizesetFontSizesetMark textStyle.fontSizenoneformat
Text color · highlightsetColor · toggleHighlightsetMark textStyle.color · highlight.colornoneformat
Subscript · superscript · inline codeStarterKit togglesaddMarknoneformat
Link (set / unset)setLink · unsetLinkaddMark link.{href,target,rel}noneformat
Block type / attrs · failed in session
Heading level 1–6 · paragraphtoggleHeading({level}) · setParagraphsetBlockTypenoneset_block
Alignment L/C/R/JtoggleTextAlignsetNodeMarkup textAlignnoneset_block
Indent ± · line spacing · space before/afterincreaseIndent / setLineSpacingsetNodeMarkupnoneset_block
Blockquote · code blockStarterKit (no toolbar today)wrap · setBlockTypenoneset_block
Lists · partial in session
Toggle bullet ↔ orderedtoggleBulletList · toggleOrderedListwrapInList / liftListItemnone (delete-then-insert)list_op
Sink / lift list-item nestingsinkListItem · liftListItemliftListItemnonelist_op
Structural inserts · long tail
Page break · horizontal ruleinsertContentinsertContent({type:"pageBreak"})noneinsert_node (later)
Insert tableinsertTable({rows,cols})build full table>row>cell treenoneinsert_table (later)
Insert imagesetImageinsertContent({type:"image"})noneinsert_image (rare for Lawrence)
Out of scope
Comment add / resolvesetComment · toggleCommentResolvedcomment markn/anot Lawrence's job
Undo / redo · page size · letterheadeditor-only staten/an/an/a

~18 rows above. Three command families: format + set_block + list_op cover everything except long tail. Long tail adds maybe 3 more variants.

3. The half-built capability you were already paying for

The single most useful finding: the editor already accepts format-only suggestions. The diffSuggestion mark has a formatChange attr that carries { type, addedMarks, removedMarks }. lawrence-api just never builds one.

lawrence-api today · hardcoded null

apis/lawrence-api/src/content/vfs/resolvers/diff-suggestion-builder.ts:48-62

  return {
    id: randomUUID(),
    originalText,
    suggestedText,
    comment: params.comment ?? null,
    revisionId: params.revisionId ?? null,
    author: params.author ?? AGENT_AUTHOR_NAME,
    date,
    source: "user",
    formatChange: null,   // ⚠ hardcoded literal null
  }

The return type uses the literal null, not FormatChange | null. Changing this is a typed fix, not a runtime one.

OOXML schema · already there

ooxml/packages/schema/src/mark-attrs.ts:47-62

const formatChangeSchema = z.object({
  type: z.enum(["add", "remove", "replace"]),
  addedMarks:   z.array(z.record(z.string(), z.unknown())),
  removedMarks: z.array(z.record(z.string(), z.unknown())),
})

export const diffSuggestionAttrsSchema = z.object({
  id, originalText, suggestedText,
  comment, revisionId, author, date,
  source: z.enum(["imported", "user", "agent"]),
  formatChange: formatChangeSchema.nullable(),
})

Editor accept/reject paths handle it at diff-suggestion-mark.ts:357-400 (accept) and :562-628 (reject).

So the formatting unblock is keystone-shaped: change the constructor to accept a formatChange argument · update the type · grow the schema variant · grow the planner · grow the apply switch. Five small files, one feature.

4. The pipeline, with the proposed shape

User "bold it" Agent (LLM) chat-agent VFS edit tool platform-vfs Edit schema + format + set_block + list_op Planner 3 new planners emit 3 new op kinds Yjs apply + add-format-mark + set-block-attrs + list-op Editor extension formatChange ✓ Suggestion builder formatChange: { … } ✓ ↑ schema validates the new fields end-to-end ↑ formatChange flows through; editor renders format-only suggestions
Same five boundaries as the RCA's broken-pipeline diagram, all green now. Schema and planner grow by three command variants; yjs-apply by three op kinds; the suggestion builder stops hardcoding null.

5. Two editor wirings — pin the work to the right one

Worth knowing before you start: legal-os has two editor stacks in flight.

Legacy · features/documents/editor/

Custom StarterKit subset wired through features/content/adapters/documents/document-extensions.ts:30-63. Diff-suggestion is a node (diff-suggestion-extension.ts:59). Distinct toolbar.

Bedrock-track · features/document-editor/ ← target this

Uses getOoxmlExtensions() from @lawhive/ooxml-extensions (utilities/document-editor-extensions.ts:15). Diff-suggestion is a mark (schema.ts:256). This is the schema lawrence-api targets — yjs-apply.ts:44 requires schema.marks["diffSuggestion"].

All proposed changes anchor to the mark-based new stack. The legacy stack will eventually fold in.

6. The three new variants · concrete schema sketches

Field names match what's already in ooxml/packages/schema/src/{attrs,mark-attrs}.ts and the extension files — copy-paste, not invent.

6.1 · format — inline marks only

// apis/lawrence-api/src/content/vfs/schemas/matter-document-edit-schemas.ts

const InlineMarkKindSchema = z.enum([
  "bold", "italic", "underline", "strike",
  "subscript", "superscript", "code",
  "textStyle", "highlight", "link",
])

const FormatAttrsSchema = z.object({
  fontFamily: z.string().optional(),       // → textStyle.fontFamily   · mark-attrs.ts:9
  fontSize:   z.string().optional(),       // → textStyle.fontSize      · "12px"
  color:      z.string().optional(),       // → textStyle.color
  textDecorationLine: z.string().optional(),// → textStyle.textDecorationLine
  highlightColor: z.string().optional(),   // → highlight.color         · mark-attrs.ts:5
  href:       z.string().optional(),       // → link.href               · mark-attrs.ts:16
}).strict()

const FormatCommandShape = z.object({
  type: z.literal("format"),
  explanation: ExplanationSchema,
  mark: InlineMarkKindSchema,
  op: z.enum(["set", "unset", "toggle"]),
  attrs: FormatAttrsSchema.optional(),   // required for textStyle/highlight/link
  as_suggestion: z.boolean().default(true),
}).extend(TargetingCoreSchema.shape)

6.2 · set_block — node-type change + node attrs

const BlockKindSchema = z.enum([
  "paragraph", "heading", "blockquote", "codeBlock",
])

const BlockAttrsSchema = z.object({
  level: z.number().int().min(1).max(6).optional(),               // heading only · attrs.ts:61
  textAlign: z.enum(["left", "center", "right", "justify"]).optional(),
  indentLeft: z.number().optional(),     // twips · attrs.ts:53
  indentRight: z.number().optional(),
  indentFirstLine: z.number().optional(),
  lineSpacing: z.number().optional(),
  spaceBefore: z.number().optional(),
  spaceAfter: z.number().optional(),
  styleId: z.string().optional(),
}).strict()

const SetBlockCommandShape = z.object({
  type: z.literal("set_block"),
  explanation: ExplanationSchema,
  kind: BlockKindSchema,                  // changes node type when different from current
  attrs: BlockAttrsSchema.optional(),
  clear_inline_styles: z.boolean().default(false),   // see footgun #2
  as_suggestion: z.boolean().default(true),
}).extend(TargetingCoreSchema.shape)

6.3 · list_op — list structural toggle

const ListOpCommandShape = z.object({
  type: z.literal("list_op"),
  explanation: ExplanationSchema,
  kind: z.enum([
    "toggle_bullet",   // wrap target in bulletList, or lift
    "toggle_ordered",  // wrap target in orderedList, or lift
    "sink",            // nest list-item one level deeper
    "lift",            // outdent list-item one level
    "convert",         // bulletList ↔ orderedList preserving items
  ]),
  ordered_start: z.number().int().optional(),     // only when kind = toggle_ordered / convert
  ordered_type: z.string().optional(),            // "1" | "a" | "A" | "i" | "I"
  as_suggestion: z.boolean().default(true),
}).extend(TargetingCoreSchema.shape)

6.4 · Wire-up

Add the three shapes to the EditCommandSchema discriminated union at matter-document-edit-schemas.ts:55. Three corresponding planners under resolvers/planners/. Three new CommandPlan.operation.kinds in planners/types.ts:10 (add-format-mark, set-block-attrs, list-op). Three new case branches in yjs-apply.ts:101. Flip diff-suggestion-builder.ts:48-62 to accept an optional formatChange argument instead of always writing null.

Tool description at tools/platform-vfs/src/platform_vfs/editable_surfaces/matter_document_content.py:15-109 gets three new variants with the same shape descriptions above.

7. Footguns the schema enforces

These are the non-obvious gotchas that make "just emit ProseMirror" sound easy but become painful at apply time. The DSL surfaces them as schema constraints; raw ProseMirror would let them slip through silently.

  1. Underline / strike have two encodings. schema.ts:231-233 declares standalone underline and strike marks, but text-style-mark.ts:107-200 also writes them as textStyle.textDecorationLine ("underline" / "line-through"). The toolbar uses textStyle. DSL must route underline/strike through textStyle.textDecorationLine to match what the editor and DOCX serializer agree on. Otherwise round-trip silently drops it.
  2. Heading switch must clear inherited textStyle. Legacy toolbar at document-editor-toolbar.tsx:164-181 does unsetMark("textStyle") before toggleHeading — otherwise an H1 inherits the surrounding paragraph's small font-size and renders too small. The planner has to replicate this. Hence the clear_inline_styles: true flag on set_block above.
  3. numId on lists is opaque DOCX state. OrderedListPlus deliberately nulls numId when a user creates a list (ordered-list-plus.ts:75-79) to avoid colliding with imported document numbering. The DSL must not let the model invent a numId; only ever inherit or null. That's why the list_op schema above takes ordered_start / ordered_type only — not numId.
  4. formatChange is structurally open but practically constrained. mark-attrs.ts:49-50 types addedMarks / removedMarks as z.record(z.string(), z.unknown()) — anything goes at parse time. The editor only validates at reject time (diff-suggestion-mark.ts:381-396); unknown marks silently no-op on reject. The DSL must enumerate the allowed marks (the InlineMarkKindSchema above) so the LLM can't invent names that look fine until someone hits "reject".
  5. Format-only suggestions need an explicit empty-text marker. Editor classifies by formatChange && !originalText && !suggestedText (diff-suggestion-mark.ts:357, 562). A format command emitted as a tracked suggestion has to set both texts to "" literally, or the editor misclassifies it as an insertion or replacement and the accept/reject UX breaks. The planner for format with as_suggestion: true must construct the diff-suggestion mark accordingly.
  6. textAlign enum is L/C/R/J only (attrs.ts:56) — no "start" / "end". DSL matches the closed enum.
  7. heading.level is 1–6 in schema (attrs.ts:61) but toolbar only exposes 1–3 (editor-toolbar.tsx:153-186). DSL accepts 1–6 — wider is fine, model output should mirror toolbar in practice.

8. Where this still doesn't fit

Three cases where named commands strain — flagging now to avoid surprise later:

Table cell mutations

Merge / split cells, add / remove rows or columns. These come from prosemirror-tables (addColumnAfter, mergeCells). They have natural names but each is its own variant. Lawrence almost never needs this — skip until a real ask arrives, then add a table_op family with several kinds.

Cross-block surgery

"Wrap these three paragraphs in a blockquote, split out the middle one as a list, drop a heading in between." Composite. The right answer is emit multiple commands in one batch — the existing batch pipeline (MAX_EDIT_COMMANDS = 50 at matter-document-edit-schemas.ts:3) handles this fine.

Comments / footnotes / bookmarks

The schema has comment as a mark (schema.ts:265) and footnoteReference as a node (schema.ts:201). Lawrence doesn't create these today — they're authored in-editor by lawyers. Out of scope until product asks for it.

9. Effort & sequencing

#StepFilesEffort
1Drop the hardcoded formatChange: nulldiff-suggestion-builder.ts:48-62XS · ½ day
2Inherited-marks fix on replace (RCA fix #4)yjs-apply.ts:122-128S · ½ day
3format command end-to-end (schema + planner + apply + builder + tool description)5 filesM · 3-5 days
4set_block command end-to-end5 filesM · 3-5 days
5list_op command end-to-end5 files + prosemirror-schema-list helpersM · 2-3 days
6Tool description in matter_document_content.py:15-109 — document the three new variants and their footgunsmatter_document_content.pyS
7Optional long-tail: insert_node / insert_table / table_opsame surface, largerM each · defer

Order this 1 → 6. Steps 3, 4, 5 are independent and can parallelise across the team.

10. The one-line gist

The editor's surface is small and finite. Three new DSL variants (format, set_block, list_op) carry 95% of realistic Lawrence asks. The DSL pays its way as the anchor-targeting + tracked-suggestion + per-command recovery layer that raw ProseMirror would force you to rebuild.

Appendix · file:line anchors

Schema (ooxml monorepo)
Editor extensions
lawrence-api · where the new code lands
platform-v3 legal-os · editor wiring
Subagent's raw enumeration (~9 KB markdown)

Full per-row report with every schema attr and footguns at /tmp/ooxml-editor-surface.md in the local working tree. Mirrored at test-case-employment-settlement/edit-dsl-architecture-2026-05-11.md.