Runner architecture
The runtime that loads plugins, fires them on a trigger, and signs txs as the vault's operator.
The runner is the strategy execution runtime: a Node daemon that loads N plugins (one per active strategy), fires each one on a trigger (cron, event, manual), and signs transactions as the vault's operator_address through @automark/sdk.
Core principle: the plugin returns declarative intent; the runtime executes. The plugin never touches a key, never calls RPC directly, never builds tx.moveCall.
That buys three properties. The plugin is a pure function, testable without mocks. A buggy plugin can't escape the runtime: at worst it returns a bad Action[], which hits the vault's risk caps. And the path is ready for a sandbox once external builders come on board.
The four layers
Plugin decide(ctx) → Action[] — no signer, no RPC, no tx
▲
Trigger adapter translates a stimulus (cron · event · manual) into decide()
▲
Executor Action[] → moveCall via SDK → signs, submits, records digest
▲
Runtime core scheduler, wallet, retry, logs, metrics, healthcheck, isolationThe plugin contract
interface StrategyPlugin {
/** Stable name for logs, metrics, state. Unique per runtime instance. */
name: string;
/** Vault this plugin operates. One plugin operates a single vault. */
vaultId: string;
/** When the runtime should invoke decide(). Multiple triggers OK. */
triggers: Trigger[];
/**
* Decision. Receives fresh state, returns declarative intent.
* Does NOT touch a key, does NOT call RPC directly, does NOT build a tx.
*/
decide(ctx: PluginContext): Promise<Action[]>;
}
type Trigger =
| { kind: "cron"; everySeconds: number }
| { kind: "event"; topic: string } // indexer pg_notify topic
| { kind: "manual" }; // fired via CLINon-obvious points about the interface:
- A
noopwith areasonis mandatory. A plugin that decides to "do nothing" has to say why. The reason becomes a structured log of why the strategy didn't act; without it, debugging behavior is impossible. - Multiple Actions become a single PTB. Returning
[mintBinary, mintRange]from onedecide()assembles sequential moveCalls in the same tx. - State is externalized and optional. The plugin doesn't keep state in memory (it's lost on restart).
ctx.stateis a key-value store scoped per plugin name. A stateless plugin ignores it, at no cost. - Multiple triggers on the same plugin. It can react to time (cron) and to flow (event) at once.
- No automatic mutex. If two triggers fire at the same time,
decide()runs twice in parallel, and the plugin owns its idempotency via state. A global mutex was rejected because it would stall the whole pipeline on the latency of one tx.
Available triggers
| Trigger | What for |
|---|---|
cron | The base case. Every strategy needs a periodic tick. |
event | Reacts to the indexer's realtime pipeline via pg_notify; the plugin subscribes to a topic. |
manual | Debug and emergency ops, fired from the CLI. |
Runner vs keeper
The runner is not apps/keeper. They are different jobs:
| Aspect | keeper | runner |
|---|---|---|
| Auth | Permissionless (anyone can run it) | Privileged (the operator_address key) |
| Purpose | System maintenance (NAV refresh, settle, fees) | Proprietary strategy execution |
| Who runs it | Protocol and volunteers | Vault owner |
They share @automark/runtime-core (scheduler, signer, retry, observability) without mixing semantics.
Who the operator is
A discretionary vault has exactly one operator_address. If the runtime operates a plugin while a human triggers the same action through a UI, both sign with the same key. That's valid for the contract but an operational mess: double execution, and nobody knows what the other did.
Rule: per vault, pick one regime. Either the operator_address is the runtime's wallet (bot-operated, the manual UI exposes no actions), or it's the human's wallet (manually operated, the runtime loads no plugin for it). Don't mix. The "bot plus human override" case needs a 2-of-N multisig operator, a special case.
Failure isolation
A plugin that crashes or throws does not bring down the runtime. The runtime catches it, logs it, marks the plugin unhealthy (skips it for a few ticks), and carries on with the others.