How to add a new action type

This guide walks through the steps to add a new Action subtype to the engine — for example, UpdateComment to alter a table or column comment.

1. Define the action in the domain

Add a frozen dataclass to src/delta_engine/domain/plan/actions.py:

@dataclass(frozen=True, slots=True)
class UpdateComment(Action):
    """Change the comment on a column or table."""
    column_name: str
    desired_comment: str
    observed_comment: str

    aspect: ClassVar[TableAspect] = TableAspect.COLUMN_COMMENTS
    phase: ClassVar[ActionPhase] = ActionPhase.SET_COLUMN_COMMENT

    def __post_init__(self) -> None:
        if self.desired_comment == self.observed_comment:
            raise ValueError("UpdateComment carries no difference")

    @property
    def subject(self) -> str:
        return self.column_name

Note: SetColumnComment is already implemented — this is a hypothetical example showing the pattern.

Every action declares its TableAspect, carries enough desired and observed state for validation and reporting, and rejects a no-op payload when that is representable. Name each field once, semantically (desired_* / observed_* for transition state); compilers and renderers read those names directly. Action.subject determines alphabetical sort order within a phase; ActionPhase is an IntEnum — lower values run first.

2. Add a phase constant if needed

If the action belongs to a new execution phase, add it to the ActionPhase enum in the same file:

class ActionPhase(IntEnum):
    CREATE_TABLE = auto()
    ENABLE_TABLE_FEATURE = auto()
    SET_PROPERTY = auto()
    UNSET_PROPERTY = auto()
    SET_TABLE_TAG = auto()
    UNSET_TABLE_TAG = auto()
    DROP_FOREIGN_KEY = auto()
    DROP_PRIMARY_KEY = auto()
    RENAME_COLUMN = auto()
    ADD_COLUMN = auto()
    ALTER_COLUMN_TYPE = auto()
    SET_CLUSTERING = auto()
    SET_COLUMN_TAG = auto()
    UNSET_COLUMN_TAG = auto()
    DROP_COLUMN = auto()
    SET_COLUMN_COMMENT = auto()
    SET_TABLE_COMMENT = auto()
    SET_COLUMN_NULLABILITY = auto()
    SET_PRIMARY_KEY = auto()
    SET_FOREIGN_KEY = auto()
    # ADD_YOUR_NEW_PHASE = auto()

ActionPlan sorts by phase then subject automatically — no changes needed there.

When choosing a phase, mind the rename boundary: a plan may rename columns, and RENAME_COLUMN is the point where the table’s column names switch from observed to desired. An action phased before it may reference only observed column names (or constraint names, which the rename does not touch); an action phased after it must use desired names.

3. Emit the action directly from the differ

Update the relevant helper in src/delta_engine/domain/plan/diff.py. For example, a matched column comment pair emits the action itself:

if desired.comment != observed.comment:
    actions.append(
        UpdateComment(
            column_name=desired.name,
            desired_comment=desired.comment,
            observed_comment=observed.comment,
        )
    )

Actions join the diff’s actions tuple, so no union edit is needed. If the comparison cannot be represented as an action, add a frozen difference type in unresolvable.py, name it in Unresolvable, and emit it into the diff’s unresolvable tuple from diff.py. Decide whether it is accepted or rejected in application validation; all four current unresolvable differences are rejected by the default policy. Successful plan_changes results must contain actions only.

4. Register a SQL compiler

In src/delta_engine/adapters/databricks/sql/compile.py, register a singledispatch handler:

@_compile_action.register
def _compile_update_comment(action: UpdateComment, target: _Target) -> str:
    col = backtick(action.column_name)
    comment = quote_literal(action.desired_comment)
    return f"{target.alter_clause} ALTER COLUMN {col} COMMENT {comment}"

Each handler receives a _Target — the table as statements address it. It renders the backticked table name (target.name) and a relation-correct target.alter_clause without exposing relation kind. The latter is pre-rendered with ALTER TABLE ... or ALTER STREAMING TABLE ... as appropriate. Planning has already accepted the action before compilation. Actions carry only the inputs their operation needs: for example, adding a foreign key carries its complete declaration, while dropping one carries its concrete catalog name. The compiler owns the Databricks syntax for those inputs; it does not recover state or make planning decisions.

Use backtick for identifiers and quote_literal for string literals (both in delta_engine/adapters/databricks/sql/dialect.py).

5. Register a diff rendering arm

In src/delta_engine/application/diff_entries.py, register a singledispatch arm on difference_entries so the action shows up in reports. Return one or more DiffEntry values — each tags the line with a DiffCategory (columns, keys, clustering, partitioning, features, properties, tags, comments) and a DiffOperation (rendered +/-/~), names the target in subject, and carries each descriptive phrase as a separate detail element so the text renderer can align them into columns:

@difference_entries.register
def _(action: UpdateComment) -> tuple[DiffEntry, ...]:
    return (
        DiffEntry(
            DiffCategory.COMMENTS,
            DiffOperation.CHANGE,
            subject=f"column {action.column_name}",
            detail=(f"'{action.desired_comment}'",),
        ),
    )

An action may emit several entries across categories (CreateTable lists its columns and its primary key), and category grouping in the diff is display-only — it never changes execution order. test_every_action_type_has_registered_diff_entries fails if an action has no arm.

6. Add a validation rule if needed

If the new action can be unsafe, add a rule in src/delta_engine/application/validation.py. Rules receive the self-contained drift and match concrete types from drift.actions (or drift.unresolvable when judging unresolvable differences). The eligibility checks run before any safety rule and short-circuit the safety stage on failure, so a rule only ever sees differences the declaration manages — declare the correct TableAspect on the action (step 1) and no scope filtering is needed here:

from typing import ClassVar
from delta_engine.application.failures import ValidationFailure
from delta_engine.domain.plan import TableDrift
from delta_engine.domain.plan.actions import UpdateComment


class NoUnsafeCommentChange:
    name: ClassVar[str] = "NoUnsafeCommentChange"

    def evaluate(self, drift: TableDrift) -> tuple[ValidationFailure, ...]:
        return tuple(
            ValidationFailure(
                rule_name=self.name,
                message=f"Operation not allowed: ...",
            )
            for change in drift.actions
            if isinstance(change, UpdateComment) and <condition>
        )

Add it to DEFAULT_SAFETY_RULES in the same file. plan_changes deliberately exposes no rules parameter: accepted plans always use this default policy.

7. Write tests

Add tests in:

  • tests/domain/plan/test_diff.py — does diff_table emit UpdateComment directly with both states?

  • tests/domain/plan/test_actions.py — does the action declare its aspect, invariants, phase, and compiler properties?

  • tests/application/test_planning.py — is the action accepted or rejected at the total planning boundary?

  • tests/adapters/databricks/sql/test_compile.py — does the compiler produce the correct SQL?

  • tests/application/test_rendering.py — does the action render its expected diff entries?

  • tests/application/test_validation.py — if you added a rule, does it fire correctly?

Run:

uv run pytest tests/ -v

Expected: all tests pass and coverage stays above the configured threshold.