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:
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:
- Declare the narrowest useful
expectscontext. Do not hide required data inside an untyped blob when stable fields are known. - Return structured
ActionResult.outputsso follow-ups do not have to parse messages. - Attach an explicit
ActionExecutionContract. Keep unknown fields unknown rather than guessing. - 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.
- 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.
- Document partial-failure behavior for multi-request operations and ambiguous remote commits.
- 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.