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.
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.
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.
Toolbar count from features/document-editor/components/toolbar/editor-toolbar.tsx:85-440. Variant count from §5 below.
Three things the DSL gives you that raw ProseMirror Steps don't:
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
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
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
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
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.
| Capability | Editor op | ProseMirror primitive | DSL today | DSL after | |
|---|---|---|---|---|---|
| Diff-suggestion lifecycle · already covered | |||||
| Insert / replace / delete text | add-mark · insert-text-with-mark · replace | tr.addMark · tr.insertText · tr.delete | edit insert delete | unchanged | |
| Refine your own suggestion | refine-mark | mutate mark attrs | refine | unchanged | |
| Withdraw your own suggestion | withdraw-mark | conditional insert/replace/delete | withdraw | unchanged | |
| Inline marks · failed in session | |||||
| Bold / italic / underline / strike | toggleBold/Italic/Underline/Strike | addMark / removeMark | none | format | |
| Font family | setFontFamily | setMark textStyle.fontFamily | none | format | |
| Font size | setFontSize | setMark textStyle.fontSize | none | format | |
| Text color · highlight | setColor · toggleHighlight | setMark textStyle.color · highlight.color | none | format | |
| Subscript · superscript · inline code | StarterKit toggles | addMark | none | format | |
| Link (set / unset) | setLink · unsetLink | addMark link.{href,target,rel} | none | format | |
| Block type / attrs · failed in session | |||||
| Heading level 1–6 · paragraph | toggleHeading({level}) · setParagraph | setBlockType | none | set_block | |
| Alignment L/C/R/J | toggleTextAlign | setNodeMarkup textAlign | none | set_block | |
| Indent ± · line spacing · space before/after | increaseIndent / setLineSpacing | setNodeMarkup | none | set_block | |
| Blockquote · code block | StarterKit (no toolbar today) | wrap · setBlockType | none | set_block | |
| Lists · partial in session | |||||
| Toggle bullet ↔ ordered | toggleBulletList · toggleOrderedList | wrapInList / liftListItem | none (delete-then-insert) | list_op | |
| Sink / lift list-item nesting | sinkListItem · liftListItem | liftListItem | none | list_op | |
| Structural inserts · long tail | |||||
| Page break · horizontal rule | insertContent | insertContent({type:"pageBreak"}) | none | insert_node (later) | |
| Insert table | insertTable({rows,cols}) | build full table>row>cell tree | none | insert_table (later) | |
| Insert image | setImage | insertContent({type:"image"}) | none | insert_image (rare for Lawrence) | |
| Out of scope | |||||
| Comment add / resolve | setComment · toggleCommentResolved | comment mark | n/a | not Lawrence's job | |
| Undo / redo · page size · letterhead | editor-only state | n/a | n/a | n/a | |
~18 rows above. Three command families: format + set_block + list_op cover everything except long tail. Long tail adds maybe 3 more variants.
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.
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/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.
Worth knowing before you start: legal-os has two editor stacks in flight.
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.
features/document-editor/ ← target thisUses 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.
Field names match what's already in ooxml/packages/schema/src/{attrs,mark-attrs}.ts and the extension files — copy-paste, not invent.
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)
set_block — node-type change + node attrsconst 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)
list_op — list structural toggleconst 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)
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.
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.
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.
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.
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.
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".
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.
textAlign enum is L/C/R/J only (attrs.ts:56) — no "start" / "end". DSL matches the closed enum.
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.
Three cases where named commands strain — flagging now to avoid surprise later:
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.
"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.
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.
| # | Step | Files | Effort |
|---|---|---|---|
| 1 | Drop the hardcoded formatChange: null | diff-suggestion-builder.ts:48-62 | XS · ½ day |
| 2 | Inherited-marks fix on replace (RCA fix #4) | yjs-apply.ts:122-128 | S · ½ day |
| 3 | format command end-to-end (schema + planner + apply + builder + tool description) | 5 files | M · 3-5 days |
| 4 | set_block command end-to-end | 5 files | M · 3-5 days |
| 5 | list_op command end-to-end | 5 files + prosemirror-schema-list helpers | M · 2-3 days |
| 6 | Tool description in matter_document_content.py:15-109 — document the three new variants and their footguns | matter_document_content.py | S |
| 7 | Optional long-tail: insert_node / insert_table / table_op | same surface, larger | M each · defer |
Order this 1 → 6. Steps 3, 4, 5 are independent and can parallelise across the team.
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.
ooxml/packages/schema/src/schema.ts:59-274 — nodes + marks declarationooxml/packages/schema/src/attrs.ts:45-58 — paragraphAttrsSchemaooxml/packages/schema/src/attrs.ts:60-62 — headingAttrsSchema (extends paragraph + level)ooxml/packages/schema/src/attrs.ts:64-78 — list attrs (bulletList, orderedList, listItem)ooxml/packages/schema/src/mark-attrs.ts:5-7 — highlightAttrsSchemaooxml/packages/schema/src/mark-attrs.ts:9-14 — textStyleAttrsSchemaooxml/packages/schema/src/mark-attrs.ts:16-21 — linkAttrsSchemaooxml/packages/schema/src/mark-attrs.ts:47-62 — formatChangeSchema + diffSuggestionAttrsSchemaooxml/packages/extensions/src/index.ts:51-117 — getOoxmlExtensions() (all-or-nothing)ooxml/packages/extensions/src/marks/text-style-mark.ts:92-200 — textStyle commands incl. underline/strike via textDecorationLineooxml/packages/extensions/src/marks/diff-suggestion-mark.ts:357-400 — accept pathooxml/packages/extensions/src/marks/diff-suggestion-mark.ts:562-628 — reject pathooxml/packages/extensions/src/nodes/paragraph-with-ooxml.ts:192-289 — line spacing, indent commandsooxml/packages/extensions/src/nodes/ordered-list-plus.ts:75-79 — numId nulled on creationapis/lawrence-api/src/content/vfs/schemas/matter-document-edit-schemas.ts:55 — add 3 variants to discriminated unionapis/lawrence-api/src/content/vfs/resolvers/planners/index.ts:14-28 — register 3 new plannersapis/lawrence-api/src/content/vfs/resolvers/planners/types.ts:10 — add 3 op kinds to CommandPlan.operationapis/lawrence-api/src/content/vfs/resolvers/yjs-apply.ts:101 — 3 new case branchesapis/lawrence-api/src/content/vfs/resolvers/diff-suggestion-builder.ts:48-62 — accept optional formatChangeapps/legal-os/src/features/document-editor/utilities/document-editor-extensions.ts:15 — Bedrock-track entry; uses getOoxmlExtensions()apps/legal-os/src/features/document-editor/components/toolbar/editor-toolbar.tsx:85-440 — new toolbar (12 capabilities)apps/legal-os/src/features/documents/editor/toolbar/document-editor-toolbar.tsx — legacy toolbar (similar)apps/legal-os/src/features/documents/editor/extensions/diff-suggestion/ — legacy diff-suggestion as nodeFull 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.