From bf7b5d391926e4400d911d674ca00f4b199d150e Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 3 Mar 2026 16:21:33 -0800 Subject: [PATCH 1/2] fix(document-api): make expected revision optional --- .../reference/_generated-manifest.json | 2 +- .../reference/mutations/apply.mdx | 3 +- .../reference/mutations/preview.mdx | 3 +- packages/document-api/src/contract/schemas.ts | 4 +- .../src/types/mutation-plan.types.ts | 4 +- .../compiler-ref-targeting.test.ts | 35 ++++++++++ .../plan-engine/compiler.ts | 70 +++++++++++++++---- .../plan-engine/executor.ts | 4 +- .../plan-engine/preview.ts | 4 +- 9 files changed, 105 insertions(+), 24 deletions(-) diff --git a/apps/docs/document-api/reference/_generated-manifest.json b/apps/docs/document-api/reference/_generated-manifest.json index f7294acd4a..8dcabaccec 100644 --- a/apps/docs/document-api/reference/_generated-manifest.json +++ b/apps/docs/document-api/reference/_generated-manifest.json @@ -494,5 +494,5 @@ } ], "marker": "{/* GENERATED FILE: DO NOT EDIT. Regenerate via `pnpm run docapi:sync`. */}", - "sourceHash": "f1dd7d6cb56f926499a024e1bdd02a3540e666cbbd102f025e7edc8ff85b83ac" + "sourceHash": "303e249af62a3721cd276b099180e178de85852d1a92328e0ba40a83c7333891" } diff --git a/apps/docs/document-api/reference/mutations/apply.mdx b/apps/docs/document-api/reference/mutations/apply.mdx index 4e7d8d14fc..21424d4729 100644 --- a/apps/docs/document-api/reference/mutations/apply.mdx +++ b/apps/docs/document-api/reference/mutations/apply.mdx @@ -30,7 +30,7 @@ Returns a PlanReceipt with per-step results for the atomically applied mutation | --- | --- | --- | --- | | `atomic` | `true` | yes | Constant: `true` | | `changeMode` | enum | yes | `"direct"`, `"tracked"` | -| `expectedRevision` | string | yes | | +| `expectedRevision` | string | no | | | `steps` | object[] | yes | | ### Example request @@ -129,7 +129,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..dc70199322 100644 --- a/apps/docs/document-api/reference/mutations/preview.mdx +++ b/apps/docs/document-api/reference/mutations/preview.mdx @@ -30,7 +30,7 @@ Returns a MutationsPreviewOutput with resolved targets and step details without | --- | --- | --- | --- | | `atomic` | `true` | yes | Constant: `true` | | `changeMode` | enum | yes | `"direct"`, `"tracked"` | -| `expectedRevision` | string | yes | | +| `expectedRevision` | string | no | | | `steps` | object[] | yes | | ### Example request @@ -119,7 +119,6 @@ Returns a MutationsPreviewOutput with resolved targets and step details without } }, "required": [ - "expectedRevision", "atomic", "changeMode", "steps" diff --git a/packages/document-api/src/contract/schemas.ts b/packages/document-api/src/contract/schemas.ts index 38cdf14ee0..b2fbcef834 100644 --- a/packages/document-api/src/contract/schemas.ts +++ b/packages/document-api/src/contract/schemas.ts @@ -2740,7 +2740,7 @@ const operationSchemas: Record = { changeMode: { enum: ['direct', 'tracked'] }, steps: arraySchema({ type: 'object' }), }, - ['expectedRevision', 'atomic', 'changeMode', 'steps'], + ['atomic', 'changeMode', 'steps'], ), output: objectSchema( { @@ -2760,7 +2760,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/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/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..ffb2cdfbf4 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 @@ -270,6 +270,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..0af3e427bc 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 @@ -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}`); @@ -1093,7 +1137,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'; From 8a515703e3a9419ec5597e33e7c50f68dd7257ec Mon Sep 17 00:00:00 2001 From: Nick Bernal Date: Tue, 3 Mar 2026 16:38:31 -0800 Subject: [PATCH 2/2] chore: centralize mutation step ops and surface supported steps in mutations docs --- .../reference/mutations/apply.mdx | 75 ++++++ .../reference/mutations/preview.mdx | 75 ++++++ .../scripts/lib/reference-docs-artifacts.ts | 54 ++++- .../src/contract/contract.test.ts | 17 ++ packages/document-api/src/contract/index.ts | 1 + .../src/contract/step-op-catalog.ts | 214 ++++++++++++++++++ .../src/types/step-manifest.types.ts | 8 +- .../capabilities-adapter.test.ts | 8 +- .../capabilities-adapter.ts | 67 +----- .../compiler-ref-targeting.test.ts | 58 +++++ .../plan-engine/compiler.ts | 9 +- 11 files changed, 514 insertions(+), 72 deletions(-) create mode 100644 packages/document-api/src/contract/step-op-catalog.ts diff --git a/apps/docs/document-api/reference/mutations/apply.mdx b/apps/docs/document-api/reference/mutations/apply.mdx index 21424d4729..0cb238a07e 100644 --- a/apps/docs/document-api/reference/mutations/apply.mdx +++ b/apps/docs/document-api/reference/mutations/apply.mdx @@ -24,6 +24,81 @@ 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 | diff --git a/apps/docs/document-api/reference/mutations/preview.mdx b/apps/docs/document-api/reference/mutations/preview.mdx index dc70199322..502a946071 100644 --- a/apps/docs/document-api/reference/mutations/preview.mdx +++ b/apps/docs/document-api/reference/mutations/preview.mdx @@ -24,6 +24,81 @@ 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 | 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/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/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 ffb2cdfbf4..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) // --------------------------------------------------------------------------- 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 0af3e427bc..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, @@ -1128,6 +1128,13 @@ export function compilePlan(editor: Editor, steps: MutationStep[], options?: Com 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); }