Skip to content

Arbitrary actions and replay safety

Call Baxter intentionally permits plugins to register arbitrary action kinds. An action executor is ultimately a Python callable associated with an ActionDefinition; it can be pure, mutate local state, call a remote API, run a subprocess, or perform another side effect.

The generic runtime cannot infer those semantics from Python code, so plugin authors must declare what the runtime can safely assume.

What “arbitrary” means

An ActionInvocation contains a kind and arguments:

- kind: example.do_something
  args:
    target: "{{ event.subject_ref }}"

The registry resolves example.do_something to a callable. Once registered, the action participates in the same validation, audit, follow-up and rule machinery as built-in actions. The distinction is that the executor's external effects are opaque to the core runtime unless its execution contract describes them.

expects and yields are data contracts, not safety claims

Event definitions declare fields they yield. Action definitions declare fields they expect. The runtime validates these context contracts before publishing or executing work.

Those schemas answer “is the data shaped correctly?” They do not answer “is it safe to run this again?” Replay safety is a separate execution contract.

Action execution contracts

Every registered action has an ActionExecutionContract. Its evidence includes:

Field Meaning
replay_safety pure read-only, locally idempotent, externally idempotent, non-idempotent, or unknown
side_effect_scope none, local, external, or unknown
authorization_scope which runtime/selected-rule authority is needed
idempotency whether a durable local/external key exists, is unnecessary, unsupported, or unknown
retry_safety whether retry is safe, unsafe, or unknown

Unknown is deliberately fail-closed. Merely calling an endpoint with an idempotency feature does not make a Call Baxter action replay-eligible: the runtime also needs durable evidence connecting the original action to the exact idempotency identity and authorization context.

Replay eligibility is evidence, not a boolean on the rule

For a recorded rule action, the policy read service reconstructs a bounded action-chain eligibility view. It compares durable evidence with the active runtime and adds blockers when evidence is missing or inconsistent. Important checks include:

  • the action belongs to an attributable rule-selected chain;
  • recorded chain topology still matches the rule contract;
  • the recorded executor contract is usable and agrees with the active executor contract;
  • idempotent contracts have a durable idempotency identity recorded with the action;
  • required executor authorization identity was recorded and is still available/unchanged;
  • non-idempotent, unknown, unsupported-idempotency, and unsafe-retry contracts remain blocked.

This distinction matters for current interface actions:

Action family Provider-level retry/idempotency behavior Core replay classification
log.write no external side effect declared pure/read-only and can satisfy replay prerequisites
file.out write/append changes local files explicitly non-idempotent; replay blocked
Bluesky mirror deterministic AT Protocol record key; putRecord targets the same record mirror contract remains fail-closed because generic durable idempotency/authorization evidence is not yet recorded
Mastodon mirror deterministic Idempotency-Key on create; edits target mapped status id provider retry protection exists, but core replay still fails closed without complete evidence
X mirror no documented create idempotency key; with state_db_path, write-ahead attempts + durable receipts + read-only timeline reconciliation prevent blind duplicate retries; native edit is opt-in and stateless compatibility retains the crash window provider recovery hardens configured retry behavior, but the generic action contract remains fail-closed without complete core idempotency/authorization provenance

Do not weaken the runtime contract simply because an individual API call looks idempotent.

on_success and on_error chains

Follow-ups are recursive ActionInvocation values:

actions:
  - kind: deploy.release
    args:
      service: api
    on_success:
      - kind: telegram.send_message
        args:
          chat_id: 123456
          text: "Release completed"
    on_error:
      - kind: file.out
        args:
          path: failures.log
          content: "release failed\n"
          mode: append

Each follow-up renders against the result of its immediate parent. A failed follow-up marks the complete chain failed. With stop_on_error: true, that chain failure prevents later top-level actions from starting. For a rule configured with both execution_mode: parallel and stop_on_error: true, Call Baxter intentionally executes top-level actions sequentially so the stop guarantee is meaningful.

on_error is recovery orchestration, not transactional rollback. If the parent already changed a remote system, a successful recovery action cannot make that side effect atomic unless the external systems themselves provide such a transaction.

Safe and unsafe compositions

Prefer: a pure action with explicit notification

actions:
  - kind: log.write
    args:
      message: "inspection completed"
    on_success:
      - kind: telegram.send_message
        args:
          chat_id: 123456
          text: "inspection completed"

log.write returns a structured result without performing a local or external mutation, so its execution contract is explicitly pure/read-only. The notification remains an external mutation and retains its own conservative replay contract.

Be explicit: two irreversible effects

actions:
  - kind: wordpress.mirror_telegram_message
    args:
      update: "{{ event.attrs.raw_update }}"
  - kind: x.mirror_telegram_message
    args:
      update: "{{ event.attrs.raw_update }}"

If the first succeeds and the second fails, the rule has partial external effects. Call Baxter records those results; it does not pretend the pair was atomic. Provider mappings/idempotency can make retry operationally safer, but replay eligibility still follows the recorded core evidence.

Guidance for plugin authors

When adding an action:

  1. Declare the narrowest useful expects context. Do not hide required data inside an untyped blob when stable fields are known.
  2. Return structured ActionResult.outputs so follow-ups do not have to parse messages.
  3. Attach an explicit ActionExecutionContract. Keep unknown fields unknown rather than guessing.
  4. Classify the actual side effect, not the desired operator experience. A remote POST is still a remote mutation even if a local mapping usually prevents duplicates.
  5. Claim idempotency only when the identity is durable at the relevant boundary and retry is truly safe. Core replay additionally needs that identity captured in action provenance.
  6. Document partial-failure behavior for multi-request operations and ambiguous remote commits.
  7. Test duplicate delivery, stale source versions, provider errors/rate limits, and recovery after state has been persisted.

The conservative rule is simple: when Call Baxter cannot prove replay safety from durable evidence, it should say so and refuse to classify the action as replay-safe.