Planner rules

Block, modify, or extend how an intent compiles into a plan.

A PlannerRule runs while an Intent is compiled into a Plan. Rules patch the plan artifact only. They can veto the edit, appendOps, replaceOps, or annotate with meta / traceTags. The engine still owns transaction lowering, apply, normalization, commit, and history.

Examples assume an engine from Quickstart.

Use planner rules for semantic policy

Planner rules are a good fit for:

  • Block edits that violate product policy.
  • Add semantic follow-up operations after an intent.
  • Replace built-in operations with another explicit operation sequence for an owned product rule.
  • Add trace metadata for audit or debugging.
  • Route product shortcuts and toolbar commands through one semantic rule set.

Write a rule

import { type PlannerRule } from "@spine-editor/core";

const forbidH1: PlannerRule = {
  id: "docs:forbid-h1",

  apply(_plan, intent) {
    if (intent.kind !== "setSelectedBlockType") return null;
    if (intent.toType !== "heading") return null;
    if (intent.attrs.level !== 1) return null;

    return {
      veto: true,
      vetoReason: { message: "H1 is reserved for page titles." },
      traceTags: ["docs", "style-policy"],
    };
  },
};

PlannerRuleResult:

interface PlannerRuleResult {
  readonly veto?: boolean;
  readonly vetoReason?: { message: string };
  readonly appendOps?: readonly PlanOp[];
  readonly replaceOps?: readonly PlanOp[];
  readonly meta?: PlanMeta;
  readonly traceTags?: readonly string[];
}

appendOps adds operations after the current plan. replaceOps replaces the current operation list entirely. Use replacement only when the rule owns the full semantic meaning of the intent on the target domain.

Register the rule

import { createEditorEngine, createPlanner } from "@spine-editor/core";

const engine = createEditorEngine({
  initialState,
  planner: createPlanner([forbidH1]),
});

Rules run in array order; later rules see the plan as patched by earlier ones.

Append a semantic operation

This rule adds a stored-mark clear after a successful link application, without touching the document directly:

import { type PlannerRule } from "@spine-editor/core";

const clearStoredMarksAfterLink: PlannerRule = {
  id: "app:clear-stored-marks-after-link",

  apply(plan, intent) {
    if (intent.kind !== "setLink") return null;
    if (plan.ops.length === 0) return null;

    return {
      appendOps: [{ kind: "setStoredMarks", storedMarks: null }],
      traceTags: ["app", "link-policy"],
    };
  },
};

The engine still lowers the final plan into a transaction, applies it, normalizes it, commits it, and records history.

Annotate plans for analytics or audit

import { type PlannerRule } from "@spine-editor/core";

const auditTyping: PlannerRule = {
  id: "app:audit-typing",

  apply(plan, intent) {
    if (intent.kind !== "typeText") return null;
    if (plan.ops.length === 0) return null;

    return {
      meta: {
        source: "keyboard",
        insertedLength: intent.text.length,
      },
      traceTags: ["app", "typing"],
    };
  },
};

Metadata and trace tags travel with the plan artifact. They are useful for inspection tools, replay logs, and product diagnostics.

Handle a veto

import { PlanVetoedError } from "@spine-editor/core";

try {
  engine.dispatch(intent);
} catch (err) {
  if (err instanceof PlanVetoedError) {
    showToast(err.reason?.message ?? "Edit reserved by policy");
    return;
  }
  throw err;
}

Register through a plugin

import type { Plugin, PluginHookId, PluginId } from "@spine-editor/core";

const docsStyle: Plugin = {
  id: "docs-style" as PluginId,
  plannerRules: [
    {
      id: "docs-style:forbid-h1" as PluginHookId,
      rule: forbidH1,
    },
  ],
};

const engine = createEditorEngine({
  initialState,
  planner: createPlanner(),
  plugins: [docsStyle],
});

Plugins are a packaging and ordering mechanism. They can contribute planner rules and commands, and their capability declarations make ordering deterministic.

Test the rule at the plan and engine levels

For a narrow rule, assert the compiled plan shape. For behavior that affects committed state, dispatch through an engine and assert both the transaction and stateAfter. If the rule changes a browser-visible workflow, add DOM integration coverage around the runtime behavior that emits the intent.