# Extension points

> What you can implement (stores, LLM providers, memory backends, schedule backends), which methods are frozen, how optional capabilities are detected, and the change policy.

Source: https://docs.pyrula.io/reference/extension-points/

Four interfaces are meant to be implemented by code outside Pyrula: the store ABCs, `LLMClient`,
`MemoryBackend` and `ScheduleBackend`. They carry the `extension` tier on the
[stability page](https://docs.pyrula.io/getting-started/stability.md). The tier makes one promise about the future: an
implementation that works today keeps working after a minor release adds to the interface.

## The rule

The abstract core of each interface is frozen at the first tag. After the tag:

- A method added to an extension point is never abstract. It ships with a default that raises
  `NotImplementedError` or degrades to a documented behavior, and Pyrula calls it only after a
  capability check.
- A keyword added to an existing method is passed only to implementations whose signature takes
  it. You never need `**kwargs` to survive an upgrade.
- A change to a signature or a frozen method goes through the
  [deprecation rule](https://docs.pyrula.io/getting-started/stability.md#deprecations): a minor release warns, the next
  one removes.

Before the tag, nothing is published, so the core can still change. A method stays in the core
when the engine calls it on every use of the feature its mixin claims and has no safe degrade.
Demoting such a method would only move a startup error into the middle of a run.

## Stores

`Store` combines the ABCs in `pyrula.contracts.stores`. Implement the mixins you need and pass the
result where a store goes (`CompositeStore` joins separate lifecycle and event-log backends).

| ABC | Abstract core | Notes |
|---|---|---|
| `LifecycleStore` | 23 methods (submit, claim, status, pause and cancel controls, results, leases) | `fail_interrupt`, `hand_back_run`, `terminate_run`, `apply_retention`, `submitted_at`, `get_pause_requested_at`, `note_replay_check_timeout`, the run-version methods (`get_run_version`, `adopt_run`, `register_version`, `version_rank`, `get_run_rank`) and the binding methods (`get_store_binding`, `bind_store`, `clear_store_binding`) have defaults; see capabilities |
| `EventLogStore` | `append_event`, `load_run`, `replay_run`, `get_last_event`, `read_entries_from` | `SequencedEventLogStore` adds `append_event_sequenced` |
| `IdempotencyStore` | `get_idempotency_run`, `set_idempotency_run` | |
| `CacheStore` | `cache_get`, `cache_set` | |
| `KVStore` | `kv_get`, `kv_put`, `kv_delete`, `kv_list`, `kv_search` | |
| `TimerStore` | `set_sleeping`, `get_sleeping_runs`, `wake_run`, `wake_due` | `reopen_wait` and `time_out_wait` are defaults |
| `SignalStore` | `send_signal`, `peek_signal_or_arm`, `pop_signal_match` | |
| `UpdateStore` | the five update methods, `deliver_update` through `read_update_result` | |
| `ChainStore` | the seven chain methods, `get_chain_tail` through `read_inbox_seal` | |

`SignalStore`, `UpdateStore`, `ChainStore` and the sleeping core of `TimerStore` stay abstract
because a store that opts into the feature must provide all of it. A store that does not need
signals, updates or chains does not inherit those mixins, so the gap shows up when you build the
store rather than in the middle of a run.

These methods are defaults that raise `NotImplementedError` or degrade, each behind a capability:

| Method | Capability | Without it |
|---|---|---|
| `TimerStore.reopen_wait` | `wait_reopen` | The attempt parks without re-journaling the opening |
| `TimerStore.time_out_wait` | `wait_timeout` | The holder does not close its own wait; the timer does |
| `LifecycleStore.fail_interrupt` | `interrupt_fail` | The failure is logged and the run stays parked |
| `LifecycleStore.hand_back_run` | `hand_back` | A run the worker declines (a journal or version it cannot read) is released and left to lease recovery, which counts an attempt; logged once |
| `LifecycleStore.terminate_run` | `terminate` | A stuck run cannot be ended from outside; the call raises `NotImplementedError` |

`LifecycleStore.get_pause_requested_at` defaults to reading `get_run_controls`, and the module
function `pyrula.workflows.continuation.resolve_continuation` follows `run:continued` events without the tail record or seals when the
store lacks `chains`. `clear_pause` is not part of any interface. Resume a paused run with
`resume_paused_run`.

### Capabilities

`store.capabilities()` returns a `frozenset[str]` naming the optional groups a store provides. The
default on `LifecycleStore` reports a group when the class overrides every method in it.
`store_capabilities(store)` in `pyrula.contracts.stores` is the one place Pyrula asks: it calls
`capabilities()` when the store has it, and otherwise checks for the methods, so a duck-typed store
still works.

| Capability | Methods |
|---|---|
| `timers` | `set_sleeping`, `get_sleeping_runs`, `wake_run`, `wake_due` |
| `signals` | `send_signal`, `peek_signal_or_arm`, `pop_signal_match` |
| `updates` | `deliver_update`, `peek_pending_updates`, `ack_pending_update`, `write_update_result`, `read_update_result` |
| `chains` | the seven chain methods |
| `wait_reopen` | `reopen_wait` |
| `wait_timeout` | `time_out_wait` |
| `interrupt_fail` | `fail_interrupt` |
| `run_versions` | `get_run_version`, `adopt_run`, `register_version`, `version_rank`, `get_run_rank` |
| `store_binding` | `get_store_binding`, `bind_store`, `clear_store_binding` |
| `hand_back` | `hand_back_run` |
| `terminate` | `terminate_run` |
| `schedules` | `schedule_due`, `schedule_claim_fire`, plus the rest of `ScheduleBackend` |
| `projection` | `drain_projection_pending` |
| `maintenance` | `run_maintenance` |

The set of names is open. A name Pyrula does not know reads as absent, and a later release adds
new capabilities as new strings. `CompositeStore`, `ArchivingStore` and the agents store adapter
forward `capabilities()` to the backend that serves each group. A wrapper you write that holds
another store overrides `capabilities()` the same way, or its inner store's capabilities read as
absent.

A store without `schedules` never fires schedules. When a worker has schedules registered and the
store lacks the capability, it logs one warning at start.

`maintenance` is for a store that has housekeeping to do, such as deleting runs past their
retention. The worker calls `run_maintenance()` once at start and then again within the number of
seconds the call returns, and never less often than `orphan_scan_interval`. A `StoreUnavailable`
from it is retried on the next tick. A `ConfigurationError` means the store has found it must not
be used, for example because another host is writing its file: it ends the loop, the workflow
worker stops and the agent runtime reports itself not ready. A worker on a store
without the capability logs one INFO line at start. The Valkey and memory stores do not offer it,
because Valkey expires its own keys and the memory store keeps nothing.

### Prove it with the contract kits

`pyrula.workflows.testing` ships a pytest contract class per interface (`LifecycleStoreContract`,
`EventLogStoreContract`, `TimerStoreContract`, `SignalStoreContract`, `UpdateStoreContract`,
`ChainStoreContract`, `KVStoreContract`, `CacheStoreContract`, `IdempotencyStoreContract` and
others). Subclass the ones you implement and provide a `store` fixture. A test for an optional
capability is skipped, not failed, when `store_capabilities(store)` lacks it, so a store that
implements only the core passes the required tests and reports the rest as skipped.

`EnvelopeContract` covers what a store does to an event it did not receive from a caller. A store
must persist `headers` on every event it writes, including the ones it builds itself (a wake, a
wait timeout, a resume, an unpause, a failed interrupt, a terminate, a requeue and the cancel and
pause audit events). The engine decides those headers: call `engine_headers(kind)` from
`pyrula.workflows.stores.event_headers` (or build the event with the engine's builders, which do)
and write what it returns. It is empty today; the contract runs under `stamped_engine_headers` so
a store that drops or never asks for them fails now, not when the engine starts stamping.
A reopened wait is the exception: it copies its opening's headers except `encoding`
(`reopened_headers`), because the copy's payload is re-encoded as JSON. A store must also return
an update payload exactly as it was sent: store the JSON verbatim and never decode and re-encode
it, which turns a 19-digit integer into a float and `[]` into `{}` in some server-side JSON
libraries.

## LLM providers

`LLMClient` in `pyrula.agents.llm.base` has two abstract methods, `stream` and `complete`, and both
are frozen. A provider that implements only those works under every agent. `tool_choice` on
`stream` is the one optional keyword today: when a provider's `stream` does not take it, a forced
tool call goes out unforced and Pyrula logs that once. New optional keywords follow the same probe,
`accepts_keyword` in `pyrula.contracts.probe`.

The `model` on an `llm:done` event is the model that answered the call. It matches the configured
model today, and it is the field to read if a fallback ever answers with another.

## Memory backends

`MemoryBackend` in `pyrula.agents.memory` is a synchronous protocol with `put`, `get`, `delete`,
`list` and `search`. The method set and signatures are frozen. `search` ranks however the backend
defines relevance, and Pyrula does not assume more than that. Vector search will arrive as a
separate optional protocol that Pyrula finds by method presence, not as new keywords on `search`.

## Schedule backends

`ScheduleBackend` in `pyrula.workflows.schedules.store` lists the methods a store implements to
fire schedules: `schedule_create`, `schedule_get`, `schedule_list`, `schedule_update`,
`schedule_advance`, `schedule_patch`, `schedule_delete`, `schedule_due` and
`schedule_claim_fire`. Support is the `schedules` capability. The protocol is never checked with
`isinstance`, so a method added to it later does not stop existing stores from firing. Pause,
resume and run now need `schedule_patch` on top; a store without it fires schedules but does
not offer those controls.

## Open enums

`RunStatus`, `ErrorCode` (both the workflow and the agents version), `StepKind`, `EventKind` (core
and agents) and `RunKind` are `StrEnum`s, and each is an open set. A release may add members.
Compare by value, give every branch on one of them a fallback, and never use an exhaustive `match`.
`RunEvent.kind` is a plain `str`: it can hold a kind no enum lists, including your own from
`ctx.emit`.

The same applies on the wire. A consumer of the event stream or of server-sent events ignores
unknown kinds and unknown fields. On the live stream, a frame without an `id:` line is ephemeral: it has no stored
position, so a reconnect with `Last-Event-ID` does not replay it. The `/replay` endpoint is the
exception to read that rule by: it sends stored, durable events and sends them without `id:`
lines, so a missing id there says nothing about durability and a client resumes it by asking again.

## MCP servers

`build_mcp_server` returns a `DurableMCPServer`. Its `sdk_server` property is the MCP SDK's own
server object, whose type belongs to the SDK and is not guaranteed across versions (`FastMCP` on
`mcp` 1.x, `MCPServer` on 2.x), so annotate it `Any`.
