diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json
index 0f7ce05945..6bdd798543 100644
--- a/apps/docs/document-api/reference/_generated-manifest.json
+++ b/apps/docs/document-api/reference/_generated-manifest.json
@@ -496,5 +496,5 @@
}
],
"marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}",
- "sourceHash": "d63e4c31b2ecb4768b8d7c22f4fca6ec66da0d674ff05b7663fa95db8791754b"
+ "sourceHash": "ee513c6250d785a2081789da920000cc75e2077ffd129acafd7ef7983c6518b1"
}
diff --git a/apps/docs/document-api/reference/mutations/apply.mdx b/apps/docs/document-api/reference/mutations/apply.mdx
index 4e7d8d14fc..0cb238a07e 100644
--- a/apps/docs/document-api/reference/mutations/apply.mdx
+++ b/apps/docs/document-api/reference/mutations/apply.mdx
@@ -24,13 +24,88 @@ Execute a mutation plan atomically against the document.
Returns a PlanReceipt with per-step results for the atomically applied mutation plan.
+## Supported step operations
+
+Use these values in `steps[].op` when authoring mutation plans.
+
+### Assert
+
+| Step op (`steps[].op`) | Description | Related API operation |
+| --- | --- | --- |
+| assert | Assert selector cardinality after mutation steps complete. | — |
+
+### Text
+
+| Step op (`steps[].op`) | Description | Related API operation |
+| --- | --- | --- |
+| text.rewrite | Rewrite matched text ranges with replacement content. | replace |
+| text.insert | Insert text before or after a matched range. | insert |
+| text.delete | Delete matched text ranges. | delete |
+
+### Format
+
+| Step op (`steps[].op`) | Description | Related API operation |
+| --- | --- | --- |
+| format.apply | Apply inline formatting patch changes to matched text ranges. | format.apply |
+
+### Create
+
+| Step op (`steps[].op`) | Description | Related API operation |
+| --- | --- | --- |
+| create.paragraph | Create a paragraph adjacent to the matched block. | create.paragraph |
+| create.heading | Create a heading adjacent to the matched block. | create.heading |
+| create.table | Create a table at the requested location. | create.table |
+
+### Tables
+
+| Step op (`steps[].op`) | Description | Related API operation |
+| --- | --- | --- |
+| tables.delete | Delete the target table from the document. | tables.delete |
+| tables.clearContents | Clear contents from a target table or cell range. | tables.clearContents |
+| tables.move | Move a table to a new position. | tables.move |
+| tables.split | Split a table into two tables at a target row. | tables.split |
+| tables.convertFromText | Convert a text range into a table. | tables.convertFromText |
+| tables.convertToText | Convert a table to plain text. | tables.convertToText |
+| tables.setLayout | Set table layout mode. | tables.setLayout |
+| tables.insertRow | Insert a row into the target table. | tables.insertRow |
+| tables.deleteRow | Delete a row from the target table. | tables.deleteRow |
+| tables.setRowHeight | Set row height in the target table. | tables.setRowHeight |
+| tables.distributeRows | Distribute row heights evenly. | tables.distributeRows |
+| tables.setRowOptions | Set row-level options (header repeat, page break, etc.). | tables.setRowOptions |
+| tables.insertColumn | Insert a column into the target table. | tables.insertColumn |
+| tables.deleteColumn | Delete a column from the target table. | tables.deleteColumn |
+| tables.setColumnWidth | Set column width in the target table. | tables.setColumnWidth |
+| tables.distributeColumns | Distribute column widths evenly. | tables.distributeColumns |
+| tables.insertCell | Insert a cell into a table row. | tables.insertCell |
+| tables.deleteCell | Delete a cell from a table row. | tables.deleteCell |
+| tables.mergeCells | Merge a range of table cells. | tables.mergeCells |
+| tables.unmergeCells | Unmerge a merged table cell. | tables.unmergeCells |
+| tables.splitCell | Split a table cell into multiple cells. | tables.splitCell |
+| tables.setCellProperties | Set properties on target table cells. | tables.setCellProperties |
+| tables.sort | Sort table rows by a column value. | tables.sort |
+| tables.setAltText | Set table alt text properties. | tables.setAltText |
+| tables.setStyle | Set table style identifier. | tables.setStyle |
+| tables.clearStyle | Clear direct table style assignment. | tables.clearStyle |
+| tables.setStyleOption | Set table style option flags. | tables.setStyleOption |
+| tables.setBorder | Set table border properties. | tables.setBorder |
+| tables.clearBorder | Clear table border properties. | tables.clearBorder |
+| tables.applyBorderPreset | Apply a border preset to a table. | tables.applyBorderPreset |
+| tables.setShading | Set table shading properties. | tables.setShading |
+| tables.clearShading | Clear table shading properties. | tables.clearShading |
+| tables.setTablePadding | Set table-level cell padding. | tables.setTablePadding |
+| tables.setCellPadding | Set cell padding for target cells. | tables.setCellPadding |
+| tables.setCellSpacing | Set table cell spacing. | tables.setCellSpacing |
+| tables.clearCellSpacing | Clear table cell spacing. | tables.clearCellSpacing |
+
+The runtime capability snapshot also exposes this allowlist at `planEngine.supportedStepOps`.
+
## Input fields
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `atomic` | `true` | yes | Constant: `true` |
| `changeMode` | enum | yes | `"direct"`, `"tracked"` |
-| `expectedRevision` | string | yes | |
+| `expectedRevision` | string | no | |
| `steps` | object[] | yes | |
### Example request
@@ -129,7 +204,6 @@ Returns a PlanReceipt with per-step results for the atomically applied mutation
}
},
"required": [
- "expectedRevision",
"atomic",
"changeMode",
"steps"
diff --git a/apps/docs/document-api/reference/mutations/preview.mdx b/apps/docs/document-api/reference/mutations/preview.mdx
index 74108cb731..502a946071 100644
--- a/apps/docs/document-api/reference/mutations/preview.mdx
+++ b/apps/docs/document-api/reference/mutations/preview.mdx
@@ -24,13 +24,88 @@ Dry-run a mutation plan, returning resolved targets without applying changes.
Returns a MutationsPreviewOutput with resolved targets and step details without applying changes.
+## Supported step operations
+
+Use these values in `steps[].op` when authoring mutation plans.
+
+### Assert
+
+| Step op (`steps[].op`) | Description | Related API operation |
+| --- | --- | --- |
+| assert | Assert selector cardinality after mutation steps complete. | — |
+
+### Text
+
+| Step op (`steps[].op`) | Description | Related API operation |
+| --- | --- | --- |
+| text.rewrite | Rewrite matched text ranges with replacement content. | replace |
+| text.insert | Insert text before or after a matched range. | insert |
+| text.delete | Delete matched text ranges. | delete |
+
+### Format
+
+| Step op (`steps[].op`) | Description | Related API operation |
+| --- | --- | --- |
+| format.apply | Apply inline formatting patch changes to matched text ranges. | format.apply |
+
+### Create
+
+| Step op (`steps[].op`) | Description | Related API operation |
+| --- | --- | --- |
+| create.paragraph | Create a paragraph adjacent to the matched block. | create.paragraph |
+| create.heading | Create a heading adjacent to the matched block. | create.heading |
+| create.table | Create a table at the requested location. | create.table |
+
+### Tables
+
+| Step op (`steps[].op`) | Description | Related API operation |
+| --- | --- | --- |
+| tables.delete | Delete the target table from the document. | tables.delete |
+| tables.clearContents | Clear contents from a target table or cell range. | tables.clearContents |
+| tables.move | Move a table to a new position. | tables.move |
+| tables.split | Split a table into two tables at a target row. | tables.split |
+| tables.convertFromText | Convert a text range into a table. | tables.convertFromText |
+| tables.convertToText | Convert a table to plain text. | tables.convertToText |
+| tables.setLayout | Set table layout mode. | tables.setLayout |
+| tables.insertRow | Insert a row into the target table. | tables.insertRow |
+| tables.deleteRow | Delete a row from the target table. | tables.deleteRow |
+| tables.setRowHeight | Set row height in the target table. | tables.setRowHeight |
+| tables.distributeRows | Distribute row heights evenly. | tables.distributeRows |
+| tables.setRowOptions | Set row-level options (header repeat, page break, etc.). | tables.setRowOptions |
+| tables.insertColumn | Insert a column into the target table. | tables.insertColumn |
+| tables.deleteColumn | Delete a column from the target table. | tables.deleteColumn |
+| tables.setColumnWidth | Set column width in the target table. | tables.setColumnWidth |
+| tables.distributeColumns | Distribute column widths evenly. | tables.distributeColumns |
+| tables.insertCell | Insert a cell into a table row. | tables.insertCell |
+| tables.deleteCell | Delete a cell from a table row. | tables.deleteCell |
+| tables.mergeCells | Merge a range of table cells. | tables.mergeCells |
+| tables.unmergeCells | Unmerge a merged table cell. | tables.unmergeCells |
+| tables.splitCell | Split a table cell into multiple cells. | tables.splitCell |
+| tables.setCellProperties | Set properties on target table cells. | tables.setCellProperties |
+| tables.sort | Sort table rows by a column value. | tables.sort |
+| tables.setAltText | Set table alt text properties. | tables.setAltText |
+| tables.setStyle | Set table style identifier. | tables.setStyle |
+| tables.clearStyle | Clear direct table style assignment. | tables.clearStyle |
+| tables.setStyleOption | Set table style option flags. | tables.setStyleOption |
+| tables.setBorder | Set table border properties. | tables.setBorder |
+| tables.clearBorder | Clear table border properties. | tables.clearBorder |
+| tables.applyBorderPreset | Apply a border preset to a table. | tables.applyBorderPreset |
+| tables.setShading | Set table shading properties. | tables.setShading |
+| tables.clearShading | Clear table shading properties. | tables.clearShading |
+| tables.setTablePadding | Set table-level cell padding. | tables.setTablePadding |
+| tables.setCellPadding | Set cell padding for target cells. | tables.setCellPadding |
+| tables.setCellSpacing | Set table cell spacing. | tables.setCellSpacing |
+| tables.clearCellSpacing | Clear table cell spacing. | tables.clearCellSpacing |
+
+The runtime capability snapshot also exposes this allowlist at `planEngine.supportedStepOps`.
+
## Input fields
| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `atomic` | `true` | yes | Constant: `true` |
| `changeMode` | enum | yes | `"direct"`, `"tracked"` |
-| `expectedRevision` | string | yes | |
+| `expectedRevision` | string | no | |
| `steps` | object[] | yes | |
### Example request
@@ -119,7 +194,6 @@ Returns a MutationsPreviewOutput with resolved targets and step details without
}
},
"required": [
- "expectedRevision",
"atomic",
"changeMode",
"steps"
diff --git a/apps/docs/document-engine/sdks.mdx b/apps/docs/document-engine/sdks.mdx
index 0f9a679f1c..55c94d4d43 100644
--- a/apps/docs/document-engine/sdks.mdx
+++ b/apps/docs/document-engine/sdks.mdx
@@ -657,6 +657,7 @@ The SDKs expose all operations from the [Document API](/document-api/overview) p
| `doc.get_node_by_id` | `get-node-by-id` | Retrieve a single node by its unique ID. |
| `doc.get_text` | `get-text` | Extract the plain-text content of the document. |
| `doc.get_markdown` | `get-markdown` | Extract the document content as a Markdown string. |
+| `doc.get_html` | `get-html` | Extract the document content as an HTML string. |
| `doc.info` | `info` | Return document metadata including revision, node count, and capabilities. |
| `doc.query.match` | `query match` | Deterministic selector-based search with cardinality contracts for mutation targeting. |
| `doc.mutations.preview` | `mutations preview` | Dry-run a mutation plan, returning resolved targets without applying changes. |
diff --git a/packages/document-api/scripts/lib/reference-docs-artifacts.ts b/packages/document-api/scripts/lib/reference-docs-artifacts.ts
index fe241db80b..3ed0d7e842 100644
--- a/packages/document-api/scripts/lib/reference-docs-artifacts.ts
+++ b/packages/document-api/scripts/lib/reference-docs-artifacts.ts
@@ -12,6 +12,7 @@ import {
OPERATION_DESCRIPTION_MAP,
OPERATION_EXPECTED_RESULT_MAP,
OPERATION_REFERENCE_DOC_PATH_MAP,
+ PUBLIC_STEP_OP_CATALOG,
REFERENCE_OPERATION_ALIASES,
REFERENCE_OPERATION_GROUPS,
type ReferenceAliasDefinition,
@@ -82,6 +83,55 @@ function renderNoWrapLinkCode(label: string, href: string): string {
return `${label}`;
}
+const STEP_DOMAIN_ORDER = ['assert', 'text', 'format', 'create', 'tables'] as const;
+const STEP_DOMAIN_LABELS: Record<(typeof STEP_DOMAIN_ORDER)[number], string> = {
+ assert: 'Assert',
+ text: 'Text',
+ format: 'Format',
+ create: 'Create',
+ tables: 'Tables',
+};
+
+function renderStepReferenceCell(referenceOperationId?: ContractOperationSnapshot['operationId']): string {
+ if (!referenceOperationId) return '—';
+ const operationPath = toOperationDocPath(referenceOperationId);
+ return renderNoWrapLinkCode(referenceOperationId, toPublicDocHref(operationPath));
+}
+
+function renderStepOpsSection(operation: ContractOperationSnapshot): string {
+ if (operation.operationId !== 'mutations.apply' && operation.operationId !== 'mutations.preview') {
+ return '';
+ }
+
+ const domainSections = STEP_DOMAIN_ORDER.map((domain) => {
+ const entries = PUBLIC_STEP_OP_CATALOG.filter((entry) => entry.domain === domain);
+ if (entries.length === 0) return '';
+
+ const rows = entries
+ .map(
+ (entry) =>
+ `| ${renderNoWrapCode(entry.opId)} | ${escapeCell(entry.description)} | ${renderStepReferenceCell(entry.referenceOperationId)} |`,
+ )
+ .join('\n');
+
+ return `### ${STEP_DOMAIN_LABELS[domain]}
+
+| Step op (\`steps[].op\`) | Description | Related API operation |
+| --- | --- | --- |
+${rows}`;
+ })
+ .filter(Boolean)
+ .join('\n\n');
+
+ return `## Supported step operations
+
+Use these values in \`steps[].op\` when authoring mutation plans.
+
+${domainSections}
+
+The runtime capability snapshot also exposes this allowlist at \`planEngine.supportedStepOps\`.`;
+}
+
// ---------------------------------------------------------------------------
// $ref resolution
// ---------------------------------------------------------------------------
@@ -563,6 +613,8 @@ function renderOperationPage(operation: ContractOperationSnapshot, $defs: Defs):
const inputExample = generateExample(operation.schemas.input, $defs);
const outputExample = generateExample(operation.schemas.output, $defs);
+ const stepOpsSection = renderStepOpsSection(operation);
+ const expectedResultSection = `${expectedResult}${stepOpsSection ? `\n\n${stepOpsSection}` : ''}`;
// -- Build raw-schema accordion blocks --
const rawSchemaBlocks: string[] = [];
@@ -599,7 +651,7 @@ ${description}
## Expected result
-${expectedResult}
+${expectedResultSection}
## Input fields
diff --git a/packages/document-api/src/contract/contract.test.ts b/packages/document-api/src/contract/contract.test.ts
index 3d4c3fa36a..29cf571098 100644
--- a/packages/document-api/src/contract/contract.test.ts
+++ b/packages/document-api/src/contract/contract.test.ts
@@ -4,6 +4,7 @@ import { OPERATION_DEFINITIONS, type ReferenceGroupKey } from './operation-defin
import { DOCUMENT_API_MEMBER_PATHS, OPERATION_MEMBER_PATH_MAP, memberPathForOperation } from './operation-map.js';
import { OPERATION_REFERENCE_DOC_PATH_MAP, REFERENCE_OPERATION_GROUPS } from './reference-doc-map.js';
import { buildInternalContractSchemas } from './schemas.js';
+import { PUBLIC_MUTATION_STEP_OP_IDS, STEP_OP_CATALOG } from './step-op-catalog.js';
import { OPERATION_IDS, PRE_APPLY_THROW_CODES, isValidOperationIdFormat } from './types.js';
describe('document-api contract catalog', () => {
@@ -194,6 +195,22 @@ describe('document-api contract catalog', () => {
}
});
+ it('keeps public mutation step ops explicit and reference-valid', () => {
+ expect(PUBLIC_MUTATION_STEP_OP_IDS.length).toBeGreaterThan(0);
+ expect(new Set(PUBLIC_MUTATION_STEP_OP_IDS).size).toBe(PUBLIC_MUTATION_STEP_OP_IDS.length);
+ expect(PUBLIC_MUTATION_STEP_OP_IDS).not.toContain('domain.command');
+ expect(PUBLIC_MUTATION_STEP_OP_IDS).toContain('assert');
+
+ const validOperationIds = new Set(OPERATION_IDS);
+ for (const stepOp of STEP_OP_CATALOG) {
+ if (!stepOp.referenceOperationId) continue;
+ expect(
+ validOperationIds.has(stepOp.referenceOperationId),
+ `${stepOp.opId} references unknown operation ${stepOp.referenceOperationId}`,
+ ).toBe(true);
+ }
+ });
+
it('marks exactly the out-of-band mutation operations as historyUnsafe', () => {
const historyUnsafeOps = OPERATION_IDS.filter((id) => COMMAND_CATALOG[id].historyUnsafe === true).sort();
diff --git a/packages/document-api/src/contract/index.ts b/packages/document-api/src/contract/index.ts
index 5b6cdb6785..11e8e2827b 100644
--- a/packages/document-api/src/contract/index.ts
+++ b/packages/document-api/src/contract/index.ts
@@ -5,3 +5,4 @@ export * from './operation-map.js';
export * from './reference-doc-map.js';
export * from './reference-aliases.js';
export * from './operation-registry.js';
+export * from './step-op-catalog.js';
diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts
index 4c536d9e4f..75fe2e1535 100644
--- a/packages/document-api/src/contract/schemas.ts
+++ b/packages/document-api/src/contract/schemas.ts
@@ -2746,7 +2746,7 @@ const operationSchemas: Record = {
changeMode: { enum: ['direct', 'tracked'] },
steps: arraySchema({ type: 'object' }),
},
- ['expectedRevision', 'atomic', 'changeMode', 'steps'],
+ ['atomic', 'changeMode', 'steps'],
),
output: objectSchema(
{
@@ -2766,7 +2766,7 @@ const operationSchemas: Record = {
changeMode: { enum: ['direct', 'tracked'] },
steps: arraySchema({ type: 'object' }),
},
- ['expectedRevision', 'atomic', 'changeMode', 'steps'],
+ ['atomic', 'changeMode', 'steps'],
),
output: objectSchema(
{
diff --git a/packages/document-api/src/contract/step-op-catalog.ts b/packages/document-api/src/contract/step-op-catalog.ts
new file mode 100644
index 0000000000..fce615260c
--- /dev/null
+++ b/packages/document-api/src/contract/step-op-catalog.ts
@@ -0,0 +1,214 @@
+import type { OperationId } from './types.js';
+
+export type StepOpDomain = 'assert' | 'text' | 'format' | 'create' | 'tables' | 'internal';
+export type StepOpSurface = 'public' | 'internal';
+
+export interface StepOpCatalogEntry<
+ OpId extends string = string,
+ Domain extends StepOpDomain = StepOpDomain,
+ Surface extends StepOpSurface = StepOpSurface,
+> {
+ opId: OpId;
+ domain: Domain;
+ surface: Surface;
+ description: string;
+ referenceOperationId?: OperationId;
+}
+
+function step<
+ const OpId extends string,
+ const Domain extends StepOpDomain,
+ const Surface extends StepOpSurface = 'public',
+>(
+ opId: OpId,
+ domain: Domain,
+ description: string,
+ options: { surface?: Surface; referenceOperationId?: OperationId } = {},
+): StepOpCatalogEntry {
+ return {
+ opId,
+ domain,
+ surface: (options.surface ?? 'public') as Surface,
+ description,
+ ...(options.referenceOperationId ? { referenceOperationId: options.referenceOperationId } : {}),
+ };
+}
+
+const STEP_OP_CATALOG_UNFROZEN = [
+ step('assert', 'assert', 'Assert selector cardinality after mutation steps complete.'),
+
+ step('text.rewrite', 'text', 'Rewrite matched text ranges with replacement content.', {
+ referenceOperationId: 'replace',
+ }),
+ step('text.insert', 'text', 'Insert text before or after a matched range.', {
+ referenceOperationId: 'insert',
+ }),
+ step('text.delete', 'text', 'Delete matched text ranges.', {
+ referenceOperationId: 'delete',
+ }),
+
+ step('format.apply', 'format', 'Apply inline formatting patch changes to matched text ranges.', {
+ referenceOperationId: 'format.apply',
+ }),
+
+ step('create.paragraph', 'create', 'Create a paragraph adjacent to the matched block.', {
+ referenceOperationId: 'create.paragraph',
+ }),
+ step('create.heading', 'create', 'Create a heading adjacent to the matched block.', {
+ referenceOperationId: 'create.heading',
+ }),
+ step('create.table', 'create', 'Create a table at the requested location.', {
+ referenceOperationId: 'create.table',
+ }),
+
+ step('tables.delete', 'tables', 'Delete the target table from the document.', {
+ referenceOperationId: 'tables.delete',
+ }),
+ step('tables.clearContents', 'tables', 'Clear contents from a target table or cell range.', {
+ referenceOperationId: 'tables.clearContents',
+ }),
+ step('tables.move', 'tables', 'Move a table to a new position.', {
+ referenceOperationId: 'tables.move',
+ }),
+ step('tables.split', 'tables', 'Split a table into two tables at a target row.', {
+ referenceOperationId: 'tables.split',
+ }),
+ step('tables.convertFromText', 'tables', 'Convert a text range into a table.', {
+ referenceOperationId: 'tables.convertFromText',
+ }),
+ step('tables.convertToText', 'tables', 'Convert a table to plain text.', {
+ referenceOperationId: 'tables.convertToText',
+ }),
+ step('tables.setLayout', 'tables', 'Set table layout mode.', {
+ referenceOperationId: 'tables.setLayout',
+ }),
+ step('tables.insertRow', 'tables', 'Insert a row into the target table.', {
+ referenceOperationId: 'tables.insertRow',
+ }),
+ step('tables.deleteRow', 'tables', 'Delete a row from the target table.', {
+ referenceOperationId: 'tables.deleteRow',
+ }),
+ step('tables.setRowHeight', 'tables', 'Set row height in the target table.', {
+ referenceOperationId: 'tables.setRowHeight',
+ }),
+ step('tables.distributeRows', 'tables', 'Distribute row heights evenly.', {
+ referenceOperationId: 'tables.distributeRows',
+ }),
+ step('tables.setRowOptions', 'tables', 'Set row-level options (header repeat, page break, etc.).', {
+ referenceOperationId: 'tables.setRowOptions',
+ }),
+ step('tables.insertColumn', 'tables', 'Insert a column into the target table.', {
+ referenceOperationId: 'tables.insertColumn',
+ }),
+ step('tables.deleteColumn', 'tables', 'Delete a column from the target table.', {
+ referenceOperationId: 'tables.deleteColumn',
+ }),
+ step('tables.setColumnWidth', 'tables', 'Set column width in the target table.', {
+ referenceOperationId: 'tables.setColumnWidth',
+ }),
+ step('tables.distributeColumns', 'tables', 'Distribute column widths evenly.', {
+ referenceOperationId: 'tables.distributeColumns',
+ }),
+ step('tables.insertCell', 'tables', 'Insert a cell into a table row.', {
+ referenceOperationId: 'tables.insertCell',
+ }),
+ step('tables.deleteCell', 'tables', 'Delete a cell from a table row.', {
+ referenceOperationId: 'tables.deleteCell',
+ }),
+ step('tables.mergeCells', 'tables', 'Merge a range of table cells.', {
+ referenceOperationId: 'tables.mergeCells',
+ }),
+ step('tables.unmergeCells', 'tables', 'Unmerge a merged table cell.', {
+ referenceOperationId: 'tables.unmergeCells',
+ }),
+ step('tables.splitCell', 'tables', 'Split a table cell into multiple cells.', {
+ referenceOperationId: 'tables.splitCell',
+ }),
+ step('tables.setCellProperties', 'tables', 'Set properties on target table cells.', {
+ referenceOperationId: 'tables.setCellProperties',
+ }),
+ step('tables.sort', 'tables', 'Sort table rows by a column value.', {
+ referenceOperationId: 'tables.sort',
+ }),
+ step('tables.setAltText', 'tables', 'Set table alt text properties.', {
+ referenceOperationId: 'tables.setAltText',
+ }),
+ step('tables.setStyle', 'tables', 'Set table style identifier.', {
+ referenceOperationId: 'tables.setStyle',
+ }),
+ step('tables.clearStyle', 'tables', 'Clear direct table style assignment.', {
+ referenceOperationId: 'tables.clearStyle',
+ }),
+ step('tables.setStyleOption', 'tables', 'Set table style option flags.', {
+ referenceOperationId: 'tables.setStyleOption',
+ }),
+ step('tables.setBorder', 'tables', 'Set table border properties.', {
+ referenceOperationId: 'tables.setBorder',
+ }),
+ step('tables.clearBorder', 'tables', 'Clear table border properties.', {
+ referenceOperationId: 'tables.clearBorder',
+ }),
+ step('tables.applyBorderPreset', 'tables', 'Apply a border preset to a table.', {
+ referenceOperationId: 'tables.applyBorderPreset',
+ }),
+ step('tables.setShading', 'tables', 'Set table shading properties.', {
+ referenceOperationId: 'tables.setShading',
+ }),
+ step('tables.clearShading', 'tables', 'Clear table shading properties.', {
+ referenceOperationId: 'tables.clearShading',
+ }),
+ step('tables.setTablePadding', 'tables', 'Set table-level cell padding.', {
+ referenceOperationId: 'tables.setTablePadding',
+ }),
+ step('tables.setCellPadding', 'tables', 'Set cell padding for target cells.', {
+ referenceOperationId: 'tables.setCellPadding',
+ }),
+ step('tables.setCellSpacing', 'tables', 'Set table cell spacing.', {
+ referenceOperationId: 'tables.setCellSpacing',
+ }),
+ step('tables.clearCellSpacing', 'tables', 'Clear table cell spacing.', {
+ referenceOperationId: 'tables.clearCellSpacing',
+ }),
+
+ // Internal bridge op used by wrappers that execute pre-compiled plans with _handler closures.
+ step('domain.command', 'internal', 'Internal wrapper bridge op. Not a user-authored step.', {
+ surface: 'internal',
+ }),
+] as const satisfies readonly StepOpCatalogEntry[];
+
+export const STEP_OP_CATALOG: readonly StepOpCatalogEntry[] = Object.freeze(
+ STEP_OP_CATALOG_UNFROZEN.map((entry) => Object.freeze(entry)),
+);
+
+export type MutationStepOpId = (typeof STEP_OP_CATALOG_UNFROZEN)[number]['opId'];
+type InternalStepOpCatalogEntry = Extract<(typeof STEP_OP_CATALOG_UNFROZEN)[number], { surface: 'internal' }>;
+type InternalMutationStepOpId = InternalStepOpCatalogEntry['opId'];
+export type PublicMutationStepOpId = Exclude;
+
+const PUBLIC_STEP_OP_CATALOG_UNFROZEN = STEP_OP_CATALOG_UNFROZEN.filter(
+ (entry) => entry.surface === 'public',
+) as readonly StepOpCatalogEntry[];
+
+export type PublicStepOpCatalogEntry = (typeof PUBLIC_STEP_OP_CATALOG_UNFROZEN)[number];
+
+export const PUBLIC_STEP_OP_CATALOG: readonly StepOpCatalogEntry[] = Object.freeze(
+ PUBLIC_STEP_OP_CATALOG_UNFROZEN.map((entry) => Object.freeze(entry)),
+);
+
+export const KNOWN_MUTATION_STEP_OP_IDS: readonly MutationStepOpId[] = Object.freeze(
+ STEP_OP_CATALOG_UNFROZEN.map((entry) => entry.opId),
+);
+export const PUBLIC_MUTATION_STEP_OP_IDS: readonly PublicMutationStepOpId[] = Object.freeze(
+ PUBLIC_STEP_OP_CATALOG_UNFROZEN.map((entry) => entry.opId),
+);
+
+const KNOWN_MUTATION_STEP_OP_SET: ReadonlySet = new Set(KNOWN_MUTATION_STEP_OP_IDS);
+const PUBLIC_MUTATION_STEP_OP_SET: ReadonlySet = new Set(PUBLIC_MUTATION_STEP_OP_IDS);
+
+export function isKnownMutationStepOp(opId: string): opId is MutationStepOpId {
+ return KNOWN_MUTATION_STEP_OP_SET.has(opId);
+}
+
+export function isPublicMutationStepOp(opId: string): opId is PublicMutationStepOpId {
+ return PUBLIC_MUTATION_STEP_OP_SET.has(opId);
+}
diff --git a/packages/document-api/src/types/mutation-plan.types.ts b/packages/document-api/src/types/mutation-plan.types.ts
index ad06e6a3a8..e989e0deb6 100644
--- a/packages/document-api/src/types/mutation-plan.types.ts
+++ b/packages/document-api/src/types/mutation-plan.types.ts
@@ -141,14 +141,14 @@ export type MutationStep = TextRewriteStep | TextInsertStep | TextDeleteStep | S
export type ChangeMode = 'direct' | 'tracked';
export type MutationsApplyInput = {
- expectedRevision: string;
+ expectedRevision?: string;
atomic: true;
changeMode: ChangeMode;
steps: MutationStep[];
};
export type MutationsPreviewInput = {
- expectedRevision: string;
+ expectedRevision?: string;
atomic: true;
changeMode: ChangeMode;
steps: MutationStep[];
diff --git a/packages/document-api/src/types/step-manifest.types.ts b/packages/document-api/src/types/step-manifest.types.ts
index 4c2cc3fb91..609d7cdf01 100644
--- a/packages/document-api/src/types/step-manifest.types.ts
+++ b/packages/document-api/src/types/step-manifest.types.ts
@@ -1,10 +1,10 @@
/**
* Step manifest types — public, engine-agnostic metadata for step ops.
*
- * `StepManifest` is the single source of truth for schema generation,
- * docs, tool catalogs, capabilities, and wrapper generation.
- * It lives in document-api (engine-agnostic) and is consumed by
- * super-editor executor registration at runtime.
+ * These types describe a rich manifest model for mutation step operations.
+ * The current catalog used by docs and capabilities lives in
+ * `contract/step-op-catalog.ts`; this type remains available for future
+ * expansion to schema-level per-step metadata.
*/
export interface IdentityStrategy {
diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts
index d744465754..43d5ebea88 100644
--- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts
+++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type { Editor } from '../core/Editor.js';
-import { INLINE_PROPERTY_REGISTRY, OPERATION_IDS } from '@superdoc/document-api';
+import { INLINE_PROPERTY_REGISTRY, OPERATION_IDS, PUBLIC_MUTATION_STEP_OP_IDS } from '@superdoc/document-api';
import { TrackFormatMarkName } from '../extensions/track-changes/constants.js';
import { getDocumentApiCapabilities } from './capabilities-adapter.js';
@@ -77,6 +77,12 @@ describe('getDocumentApiCapabilities', () => {
expect(operationKeys).toEqual([...OPERATION_IDS].sort());
});
+ it('reports planEngine step-op support from the canonical mutation step catalog', () => {
+ const capabilities = getDocumentApiCapabilities(makeEditor());
+ expect(capabilities.planEngine.supportedStepOps).toEqual(PUBLIC_MUTATION_STEP_OP_IDS);
+ expect(capabilities.planEngine.supportedStepOps).not.toContain('domain.command');
+ });
+
it('marks namespaces as unavailable when required commands are missing', () => {
const editor = makeEditor({
commands: {
diff --git a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts
index 3c5751e979..fd71740a3a 100644
--- a/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts
+++ b/packages/super-editor/src/document-api-adapters/capabilities-adapter.ts
@@ -5,6 +5,7 @@ import {
INLINE_PROPERTY_BY_KEY,
INLINE_PROPERTY_KEY_SET,
INLINE_PROPERTY_REGISTRY,
+ PUBLIC_MUTATION_STEP_OP_IDS,
type CapabilityReasonCode,
type DocumentApiCapabilities,
type InlinePropertyRegistryEntry,
@@ -334,70 +335,6 @@ function buildOperationCapabilities(editor: Editor): DocumentApiCapabilities['op
// Plan engine capabilities
// ---------------------------------------------------------------------------
-const SUPPORTED_STEP_OPS = [
- 'text.rewrite',
- 'text.insert',
- 'text.delete',
- 'format.apply',
- 'assert',
- 'create.paragraph',
- 'create.heading',
- 'create.sectionBreak',
- 'domain.command',
- 'sections.setBreakType',
- 'sections.setPageMargins',
- 'sections.setHeaderFooterMargins',
- 'sections.setPageSetup',
- 'sections.setColumns',
- 'sections.setLineNumbering',
- 'sections.setPageNumbering',
- 'sections.setTitlePage',
- 'sections.setOddEvenHeadersFooters',
- 'sections.setVerticalAlign',
- 'sections.setSectionDirection',
- 'sections.setHeaderFooterRef',
- 'sections.clearHeaderFooterRef',
- 'sections.setLinkToPrevious',
- 'sections.setPageBorders',
- 'sections.clearPageBorders',
- 'create.table',
- 'tables.delete',
- 'tables.clearContents',
- 'tables.move',
- 'tables.split',
- 'tables.convertFromText',
- 'tables.convertToText',
- 'tables.setLayout',
- 'tables.insertRow',
- 'tables.deleteRow',
- 'tables.setRowHeight',
- 'tables.distributeRows',
- 'tables.setRowOptions',
- 'tables.insertColumn',
- 'tables.deleteColumn',
- 'tables.setColumnWidth',
- 'tables.distributeColumns',
- 'tables.insertCell',
- 'tables.deleteCell',
- 'tables.mergeCells',
- 'tables.unmergeCells',
- 'tables.splitCell',
- 'tables.setCellProperties',
- 'tables.sort',
- 'tables.setAltText',
- 'tables.setStyle',
- 'tables.clearStyle',
- 'tables.setStyleOption',
- 'tables.setBorder',
- 'tables.clearBorder',
- 'tables.applyBorderPreset',
- 'tables.setShading',
- 'tables.clearShading',
- 'tables.setTablePadding',
- 'tables.setCellPadding',
- 'tables.setCellSpacing',
- 'tables.clearCellSpacing',
-] as const;
const SUPPORTED_NON_UNIFORM_STRATEGIES = ['error', 'useLeadingRun', 'majority', 'union'] as const;
const SUPPORTED_SET_MARKS = ['bold', 'italic', 'underline', 'strike'] as const;
const REGEX_MAX_PATTERN_LENGTH = 1024;
@@ -421,7 +358,7 @@ function buildFormatCapabilities(editor: Editor): FormatCapabilities {
function buildPlanEngineCapabilities(): PlanEngineCapabilities {
return {
- supportedStepOps: SUPPORTED_STEP_OPS,
+ supportedStepOps: PUBLIC_MUTATION_STEP_OP_IDS,
supportedNonUniformStrategies: SUPPORTED_NON_UNIFORM_STRATEGIES,
supportedSetMarks: SUPPORTED_SET_MARKS,
regex: {
diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/compiler-ref-targeting.test.ts b/packages/super-editor/src/document-api-adapters/plan-engine/compiler-ref-targeting.test.ts
index ce1d5e6d76..890651f7ce 100644
--- a/packages/super-editor/src/document-api-adapters/plan-engine/compiler-ref-targeting.test.ts
+++ b/packages/super-editor/src/document-api-adapters/plan-engine/compiler-ref-targeting.test.ts
@@ -97,6 +97,64 @@ describe('compilePlan ref-targeting semantics', () => {
});
});
+describe('compilePlan step-op allowlist', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockedDeps.getRevision.mockReturnValue('0');
+ mockedDeps.getBlockIndex.mockReturnValue({ candidates: [] });
+ });
+
+ it('rejects internal-only step ops for user-authored plans', () => {
+ const editor = makeEditor();
+ const steps: MutationStep[] = [
+ {
+ id: 'internal-step',
+ op: 'domain.command',
+ where: { by: 'select', select: { type: 'text', pattern: 'x', mode: 'contains' }, require: 'first' },
+ args: {},
+ },
+ ];
+
+ try {
+ compilePlan(editor, steps);
+ } catch (error) {
+ expect(error).toBeInstanceOf(PlanError);
+ const planError = error as PlanError;
+ expect(planError.code).toBe('INVALID_INPUT');
+ expect(planError.stepId).toBe('internal-step');
+ expect(planError.message).toContain('unknown step op "domain.command"');
+ return;
+ }
+
+ throw new Error('expected compilePlan to reject internal-only step op');
+ });
+
+ it('rejects unknown table step ops instead of silently no-oping', () => {
+ const editor = makeEditor();
+ const steps: MutationStep[] = [
+ {
+ id: 'unknown-table-op',
+ op: 'tables.notReal',
+ where: { by: 'select', select: { type: 'text', pattern: 'x', mode: 'contains' }, require: 'first' },
+ args: {},
+ },
+ ];
+
+ try {
+ compilePlan(editor, steps);
+ } catch (error) {
+ expect(error).toBeInstanceOf(PlanError);
+ const planError = error as PlanError;
+ expect(planError.code).toBe('INVALID_INPUT');
+ expect(planError.stepId).toBe('unknown-table-op');
+ expect(planError.message).toContain('unknown step op "tables.notReal"');
+ return;
+ }
+
+ throw new Error('expected compilePlan to reject unknown table step op');
+ });
+});
+
// ---------------------------------------------------------------------------
// V3 ref resolution (D6, Phase 4)
// ---------------------------------------------------------------------------
@@ -270,6 +328,41 @@ describe('compilePlan V3 ref resolution', () => {
throw new Error('expected compilePlan to throw REVISION_MISMATCH');
});
+ it('allows stale V3 ref revisions when ref-revision enforcement is disabled', () => {
+ mockedDeps.getBlockIndex.mockReturnValue({
+ candidates: [{ nodeId: 'p1', pos: 0, end: 12, node: {} }],
+ });
+
+ const ref = encodeTextRefPayload({
+ v: 3,
+ rev: 'old-rev',
+ matchId: 'm:0',
+ scope: 'run',
+ segments: [{ blockId: 'p1', start: 0, end: 5 }],
+ });
+
+ const editor = makeEditor();
+ const steps: MutationStep[] = [
+ {
+ id: 'stale-ref-allowed',
+ op: 'text.delete',
+ where: { by: 'ref', ref },
+ args: {},
+ },
+ ];
+
+ const plan = compilePlan(editor, steps, { enforceRefRevision: false });
+ expect(plan.mutationSteps).toHaveLength(1);
+ expect(plan.mutationSteps[0].targets).toHaveLength(1);
+ const target = plan.mutationSteps[0].targets[0];
+ expect(target.kind).toBe('range');
+ if (target.kind === 'range') {
+ expect(target.blockId).toBe('p1');
+ expect(target.from).toBe(0);
+ expect(target.to).toBe(5);
+ }
+ });
+
it('REVISION_MISMATCH.details.refScope uses V3 scope directly (match, not inferred from segments)', () => {
mockedDeps.getBlockIndex.mockReturnValue({
candidates: [
diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts b/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts
index 10522ccef6..ae38190242 100644
--- a/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts
+++ b/packages/super-editor/src/document-api-adapters/plan-engine/compiler.ts
@@ -15,7 +15,7 @@ import type {
RefWhere,
TextAddress,
} from '@superdoc/document-api';
-import { MAX_PLAN_STEPS, MAX_PLAN_RESOLVED_TARGETS } from '@superdoc/document-api';
+import { MAX_PLAN_STEPS, MAX_PLAN_RESOLVED_TARGETS, isPublicMutationStepOp } from '@superdoc/document-api';
import type { Editor } from '../../core/Editor.js';
import type {
CompiledTarget,
@@ -45,6 +45,14 @@ export interface CompiledPlan {
compiledRevision: string;
}
+interface CompilePlanOptions {
+ /**
+ * Enforce V3 text-ref revision checks during ref resolution.
+ * When false, stale ref revisions are tolerated and resolution is best-effort.
+ */
+ enforceRefRevision?: boolean;
+}
+
function isAssertStep(step: MutationStep): step is AssertStep {
return step.op === 'assert';
}
@@ -580,9 +588,16 @@ function decodeTextRefPayload(encoded: string, stepId: string): unknown {
* Single-segment refs produce a CompiledRangeTarget; multi-segment refs
* produce a CompiledSpanTarget.
*/
-function resolveV3TextRef(editor: Editor, index: BlockIndex, step: MutationStep, refData: TextRefV3): CompiledTarget[] {
+function resolveV3TextRef(
+ editor: Editor,
+ index: BlockIndex,
+ step: MutationStep,
+ refData: TextRefV3,
+ options?: CompilePlanOptions,
+): CompiledTarget[] {
const currentRevision = getRevision(editor);
- if (refData.rev !== currentRevision) {
+ const enforceRefRevision = options?.enforceRefRevision ?? true;
+ if (enforceRefRevision && refData.rev !== currentRevision) {
throw planError(
'REVISION_MISMATCH',
`Text ref is ephemeral and revision-scoped. Re-run query.match to obtain a fresh handle.ref for revision ${currentRevision}.`,
@@ -629,7 +644,13 @@ function resolveV3TextRef(editor: Editor, index: BlockIndex, step: MutationStep,
return [buildSpanTarget(editor, index, step, segments, refData.matchId)];
}
-function resolveTextRef(editor: Editor, index: BlockIndex, step: MutationStep, ref: string): CompiledTarget[] {
+function resolveTextRef(
+ editor: Editor,
+ index: BlockIndex,
+ step: MutationStep,
+ ref: string,
+ options?: CompilePlanOptions,
+): CompiledTarget[] {
const encoded = ref.slice(5); // strip 'text:' prefix
const payload = decodeTextRefPayload(encoded, step.id);
@@ -637,7 +658,7 @@ function resolveTextRef(editor: Editor, index: BlockIndex, step: MutationStep, r
throw planError('INVALID_INPUT', 'only V3 text refs are supported', step.id);
}
- return resolveV3TextRef(editor, index, step, payload);
+ return resolveV3TextRef(editor, index, step, payload, options);
}
function resolveBlockRef(editor: Editor, index: BlockIndex, step: MutationStep, ref: string): CompiledTarget[] {
@@ -661,7 +682,13 @@ function resolveBlockRef(editor: Editor, index: BlockIndex, step: MutationStep,
// Ref handler registry — dispatches by prefix (C4)
// ---------------------------------------------------------------------------
-type RefHandler = (editor: Editor, index: BlockIndex, step: MutationStep, ref: string) => CompiledTarget[];
+type RefHandler = (
+ editor: Editor,
+ index: BlockIndex,
+ step: MutationStep,
+ ref: string,
+ options?: CompilePlanOptions,
+) => CompiledTarget[];
/**
* Prefix-based ref handler registry.
@@ -694,25 +721,42 @@ const REF_HANDLERS: Array<{ prefix: string; handler: RefHandler }> = [
{ prefix: '', handler: resolveBlockRef },
];
-function dispatchRefHandler(editor: Editor, index: BlockIndex, step: MutationStep, ref: string): CompiledTarget[] {
+function dispatchRefHandler(
+ editor: Editor,
+ index: BlockIndex,
+ step: MutationStep,
+ ref: string,
+ options?: CompilePlanOptions,
+): CompiledTarget[] {
for (const entry of REF_HANDLERS) {
if (entry.prefix === '' || ref.startsWith(entry.prefix)) {
- return entry.handler(editor, index, step, ref);
+ return entry.handler(editor, index, step, ref, options);
}
}
// Unreachable — the default handler (empty prefix) always matches
return resolveBlockRef(editor, index, step, ref);
}
-function resolveRefTargets(editor: Editor, index: BlockIndex, step: MutationStep, where: RefWhere): CompiledTarget[] {
- return dispatchRefHandler(editor, index, step, where.ref);
+function resolveRefTargets(
+ editor: Editor,
+ index: BlockIndex,
+ step: MutationStep,
+ where: RefWhere,
+ options?: CompilePlanOptions,
+): CompiledTarget[] {
+ return dispatchRefHandler(editor, index, step, where.ref, options);
}
// ---------------------------------------------------------------------------
// Step target resolution
// ---------------------------------------------------------------------------
-function resolveStepTargets(editor: Editor, index: BlockIndex, step: MutationStep): CompiledTarget[] {
+function resolveStepTargets(
+ editor: Editor,
+ index: BlockIndex,
+ step: MutationStep,
+ options?: CompilePlanOptions,
+): CompiledTarget[] {
const where = step.where;
const refWhere = isRefWhere(where) ? where : undefined;
const selectWhere = isSelectWhere(where) ? where : undefined;
@@ -720,7 +764,7 @@ function resolveStepTargets(editor: Editor, index: BlockIndex, step: MutationSte
let targets: CompiledTarget[];
if (refWhere) {
- targets = resolveRefTargets(editor, index, step, refWhere);
+ targets = resolveRefTargets(editor, index, step, refWhere, options);
} else if (selectWhere) {
const resolved = resolveTextSelector(editor, index, selectWhere.select, selectWhere.within, step.id);
targets = resolved.addresses.map((addr) => {
@@ -1046,7 +1090,7 @@ function assertNoDuplicateBlockIds(index: BlockIndex): void {
}
}
-export function compilePlan(editor: Editor, steps: MutationStep[]): CompiledPlan {
+export function compilePlan(editor: Editor, steps: MutationStep[], options?: CompilePlanOptions): CompiledPlan {
// D8: plan step limit
if (steps.length > MAX_PLAN_STEPS) {
throw planError('INVALID_INPUT', `plan contains ${steps.length} steps, maximum is ${MAX_PLAN_STEPS}`);
@@ -1084,6 +1128,13 @@ export function compilePlan(editor: Editor, steps: MutationStep[]): CompiledPlan
continue;
}
+ // Enforce explicit allowlist for user-authored mutation steps.
+ // This prevents broad prefix executors (e.g. "tables.*") from accepting
+ // unknown ops that would otherwise silently no-op at runtime.
+ if (!isPublicMutationStepOp(step.op)) {
+ throw planError('INVALID_INPUT', `unknown step op "${step.op}"`, step.id);
+ }
+
if (!hasStepExecutor(step.op)) {
throw planError('INVALID_INPUT', `unknown step op "${step.op}"`, step.id);
}
@@ -1093,7 +1144,7 @@ export function compilePlan(editor: Editor, steps: MutationStep[]): CompiledPlan
validateCreateStepPosition(step);
}
- const targets = resolveStepTargets(editor, index, step);
+ const targets = resolveStepTargets(editor, index, step, options);
// Validate insertion context for create ops (B0 invariant 5)
if (isCreateOp(step.op) && targets.length > 0) {
diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts
index 5848d9882c..6faec83830 100644
--- a/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts
+++ b/packages/super-editor/src/document-api-adapters/plan-engine/executor.ts
@@ -1446,7 +1446,9 @@ export function executePlan(editor: Editor, input: MutationsApplyInput): PlanRec
throw planError('INVALID_INPUT', 'plan must contain at least one step');
}
- const compiled = compilePlan(editor, input.steps);
+ const compiled = compilePlan(editor, input.steps, {
+ enforceRefRevision: input.expectedRevision !== undefined,
+ });
return executeCompiledPlan(editor, compiled, {
changeMode: input.changeMode ?? 'direct',
diff --git a/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts b/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts
index 75073c184c..09963b2fa7 100644
--- a/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts
+++ b/packages/super-editor/src/document-api-adapters/plan-engine/preview.ts
@@ -35,7 +35,9 @@ export function previewPlan(editor: Editor, input: MutationsPreviewInput): Mutat
try {
// Phase 1: Compile — resolve selectors against pre-mutation snapshot
- const compiled = compilePlan(editor, input.steps);
+ const compiled = compilePlan(editor, input.steps, {
+ enforceRefRevision: input.expectedRevision !== undefined,
+ });
evaluatedRevision = compiled.compiledRevision;
currentPhase = 'execute';