Skip to content

Typed action contracts

An Action connects typed models to the application services that know how to control them. Calling to_definition() produces the public ActionDefinition used by the runtime. The facade adds no runtime path and no hidden behavior.

Four models, four jobs

class ExampleModel(BaseModel):
    model_config = ConfigDict(extra="forbid", strict=True, frozen=True)


class RefundCommand(ExampleModel):
    order_reference: str


class RefundSnapshot(ExampleModel):
    order_reference: str
    order_version: int
    refundable: Money
    payment_account_reference: str


class RefundPreview(ExampleModel):
    summary: str
    amount: Money


class RefundResult(ExampleModel):
    provider_reference: str

Keep the command small. It expresses intent, not trusted business state. The preparation port resolves canonical application data and creates two separate objects:

    async def prepare(
        self, command: RefundCommand, *, context: PreparationContext
    ) -> PreparedAction[RefundSnapshot, RefundPreview]:
        del context
        amount = Money(amount=Decimal("42.50"), currency="EUR")
        effect_reference = self._effect_references.setdefault(
            command.order_reference,
            f"refund-intent:{secrets.token_hex(16)}",
        )
        return PreparedAction(
            private_snapshot=RefundSnapshot(
                order_reference=command.order_reference,
                order_version=self.order_version,
                refundable=amount,
                payment_account_reference="account:private:42",
            ),
            display_preview=RefundPreview(
                summary=f"Refund order {command.order_reference}",
                amount=amount,
            ),
            semantic_effect_reference=effect_reference,
        )
  • private_snapshot is protected before it reaches the action store. Put the exact values needed for drift detection and execution here.
  • display_preview is intentionally minimized. This is what a confirmation UI or agent may see.

The runtime validates the concrete model type returned by every port. Boundary models should use strict Pydantic configuration, Decimal for money, explicit currency, and timezone-aware datetimes.

Private snapshots cannot declare float fields, including inside nested Pydantic models or containers. ActionDefinition checks this when it is constructed, before the first live proposal reaches canonicalization. Use Decimal for financial values.

Host ports

Every action supplies these ports:

Port Application responsibility
preparation Resolve trusted business state and build private/public views.
authorization Decide who may prepare, confirm, execute, and read.
authority_evaluator Decide whether already authorized evidence satisfies the declared requirement.
state_resolver Load current state and identify material drift.
executor Apply the mutation with the supplied atomic precondition.
verifier Query the system authoritative for the effect.
commitment_provider Bind the proposal to its private snapshot with host-managed key material.
protection_codec Protect and later destroy the private snapshot.
retention Authorize privileged erasure.

Compile the definition

    action = host.to_definition()

The runtime only receives the compiled definition. It does not know that an Action subclass exists. Production services such as authorization, key custody, execution, and verification may still be separately owned and constructor-injected.

ActionRegistry is optional. Use it when one process hosts heterogeneous definitions and needs checked recovery of their four model types. Directly passing a compiled definition to ActionRuntime is simpler when the action is already known.

ActionDefinition remains public, documented plumbing. Build it directly when your integration already has separate port objects or when an inheritance facade does not fit the application's ownership model. Both routes execute the same runtime contract.

The common atomic-action defaults are effect_kind="single", safe resend disabled, immediate verification eligibility, and three verification attempts. Proposal lifetime remains explicit because it is a business-risk decision.

Single and itemized effects

Set effect_kind="single" for one indivisible financial effect. Such an action can never report partial completion.

Set effect_kind="itemized" only when the action has explicit item identities and the target can report each outcome. A partially successful executor or verifier must return at least one ItemOutcome, and at least one item must be unsuccessful. Never turn an unknown batch result into a made-up partial result.

See the definitions and ports reference for every field and protocol.