iii.observe(a, b) means: when a runs, b runs.
That is how a worker reacts to anything, whether a is a plain function, a cron tick, an HTTP
request, a queue message, or a row change.
- Triggers are removed. Every trigger type is an observable function (
croniscron::on-tick), and every trigger is an observer of that function. The trigger’sconfigbecomes the observer’sargs, or itsfiltersfor a function that takes no args. callinvokes a function:iii.callin TypeScript, Python, and Rust (call_asyncin async Python),client.Callin Go, andiii callin the CLI.- The worker that hosts a function changes its defaults with four hooks. The observer lifecycle
hooks
onObserverAddedandonObserverRemoved(for one function or every function of the worker) run as observers come and go. The observation lifecycle hooksonObservationMatchandonObservationDeliver(for one function) run per call, for each observation: the call reaching, or being considered for, one observer. Together they take over from trigger handlers and from 0.25 owner delivery (delivery: "owner",obs.emit,obs.call).
Before you upgrade
Compatibility
The 0.26 engine and SDKs negotiate the
observe.capture feature (see
Feature negotiation). Without it on both sides,
the legacy on semantics apply. They are deprecated, and support will be removed in a future
release: upgrade the engine first, then move every worker to a 0.26 SDK.
0.25 engines and SDKs remain interoperable with 0.26 engines and SDKs; however, the 0.26 SDKs no
longer have the on and deliver fields on observe(). The observers of a worker that keeps its
0.25 SDK keep working unchanged (the table above lists what else changes for such a worker, such as
dropped trigger registrations), but moving a worker to a 0.26 SDK means replacing on and
deliver with capture in the same change (see Observer on and deliver).
An observer from a worker on a 0.25 SDK or a 0.26 release candidate SDK fires on success unless its
source sets on, even when its capture names error. Set on: "error" or on: "complete" on
such an observer until the worker moves to a 0.26 SDK.
A worker that binds with registerTrigger receives no events on a 0.26 engine, and the 0.26
releases of the http, cron, queue, state, and database workers need a 0.26 engine. Upgrade the
engine, the iii workers your project runs, and every worker that binds to them in one release.
The worker gets no error for a dropped registration. The engine logs one warn per connection and
message type, such as
dropping `registertrigger` frames from this worker: this engine has no such message, so search
the engine log for dropping to find a worker that still binds the old way.
Find what to change
Search each worker for the removed APIs:- Call functions with
call. - Move triggers to observers in every worker that binds functions.
- Move a trigger type to an observable function in every worker that provides one.
- Move an owner-delivered function to hooks for
functions registered with
observe: { delivery: "owner" }. - Update engine configuration, RBAC, and tooling.
What changed
Calling functions
Each 0.25 name stays as an alias of the 0.26 one: the same function or type, marked deprecated where
the language supports it, with no runtime warning. The wire field
action is unchanged.
Binding a function
Implement Rust hooks with native async fn
ImplementAddedHook, RemovedHook, and DeliverHook with native async fn, without
#[async_trait] or #[async_trait::async_trait] on their implementations. Their public traits use
return-position impl Trait in traits (RPITIT) to require a Send future:
Observer, Call, and Vec<Observer> by value. The returned future may borrow
self across .await: its + '_ lifetime is tied to that call’s &self, not required to be
'static. The hook type itself still implements Send + Sync + 'static. Keep the future Send,
including every value held across .await; for example, release a std::sync::MutexGuard before
awaiting another operation.
These public traits are not dyn-compatible. Pass a concrete implementation or an async closure to
the hook registration methods, not Box<dyn AddedHook>, Arc<dyn RemovedHook>, or another hook
trait object. Dynamic dispatch and Box::pin(async move { hook_call.await }) belong only to the
SDK’s private adapters; hook implementations do not need to box their futures.
Closures remain supported. For shared state, use a Clone struct containing an Arc, as in the
Timers and Commands examples below, or a closure that captures an Arc and clones it into each
async move block. There is no forwarding blanket implementation for Arc<T>, because it would
overlap with the blanket implementations for Fn closures.
This change applies only to the new observer hooks. The legacy IStream trait is unchanged and
still uses #[async_trait]; keep that macro on its implementations. The Rust iii-sdk dependency
on async-trait also remains. The macros in the Before (0.25) examples below describe the old
APIs and are intentionally retained.
Trigger types and their functions
The http route config renames two fields:
api_path is path and http_method is method. Any
other trigger type T of a worker W becomes a function W::on-<event>, listed in that worker’s
README at workers.iii.dev.
The console’s asset types are the exception to that rule. A worker that injects console UI observes
ade::ui::asset once per asset, with the asset’s kind and the trigger’s path as args and its
content function as the target. The console calls the target with { path } and serves the
{ content, content_type? } it returns. A later observer of the same path replaces the earlier one.
The console worker is published as ade, and its functions use the ade:: namespace: call
ade::status, ade::workspace::open and the rest of what was console::* under the new name. The
old IDs have no aliases, so update allowlists and agent tool lists with them. The iii::console::*
stream topics, the console configuration entry and the console/... asset paths keep their names.
Providing a source
Hooks and options in each SDK. Added and removed are observer lifecycle hooks; match and delivery
are observation lifecycle hooks, which run per call:
In Python, Rust and Go, set hooks by id before you register the function, so they run for every
observer. In Rust, remove
.observe(Observe::auto()) from a function that sets no handler: every
function is observable, so it has no replacement. For the model, see
Observable functions.
Other removed SDK names
Engine, protocol, and configuration
A compose file that still lists
iii-sandbox under engine.workers fails iii compose --up with
SANDBOX_CONFIG_MOVED, and a config.yaml that lists it makes the engine refuse to start with
UNSUPPORTED_CONFIG_WORKERS. Upgrading to the sandbox worker covers the move,
any entry stored under iii-sandbox, and the binaries and VM state that 0.25 left behind.
An rbac config that sets on_trigger_registration_function_id without
on_observer_registration_function_id, or on_trigger_type_registration_function_id without
on_function_registration_function_id, makes the engine refuse to start, with an error that names
the replacement key. Setting the replacement too turns the old key into a warn at load, and the
engine ignores it. The engine ignores allowed_trigger_types and allow_trigger_type_registration
in an auth function’s result, and logs one warn per engine process the first time a result sets
them.
The configuration worker accepts ttl_seconds so an existing config loads, and logs a warn at
startup when it is not 0. An entry persists until its file is removed: the fs adapter keeps each
entry in <id>.yaml under its directory, and removing that file replays as a
configuration:deleted event. The worker registers no function that deletes an entry; behind the
bridge adapter, remove the file on the source engine. Remove ttl_seconds from the config.
Call functions with call
- Node / TypeScript
- Python
- Rust
- Go
- CLI
Before (0.25)After (0.26)
Move triggers to observers
A trigger names a type, a function, and a config. An observer names the type’s function with the config asargs, and the same target. Register the target first; until it is registered, the
observer stays pending.
A trigger handed its target the event itself. When iii delivers a call, an observer’s target
receives the Observed payload: the ids, status and timings, plus the fields the observer’s
capture names. The event is the call’s input, so a trigger’s handler reads it from the
payload’s input, and capture: ["input"] carries the event alone. A source that delivers its
calls itself, such as http::on-request or ade::ui::asset, decides what its targets receive.
An observer runs its target after successful calls of the observed function, like a trigger. To run
it after failed calls too, capture error as well; see
capture.
This cron trigger runs reports::daily-summary every day at 09:00, with metadata for the target.
With capture: ["input"], the target receives the tick as the payload’s input:
- Node / TypeScript
- Python
- Rust
- Go
Before (0.25)After (0.26)
configuration trigger’s config as args. configuration::on-updated declares no
args_format, so the engine accepts the observer, ignores the args, and runs the target for a
change to any entry. Without the input.event_type filter, the target also runs when an entry is
registered or deleted.
A durable:subscriber target had 30 minutes per message. A queue::on-message target has
observers.activation_timeout_ms (30 seconds by default), and a slower one fails the delivery with
TIMEOUT, which retries the message and then dead-letters it. Raise it in the
engine config before you move a slow consumer.
A cron trigger skipped a tick while its previous run was still going. An observer of
cron::on-tick receives every tick, so a slow target can run again before its previous run ends, up
to the observer’s max_in_flight (64 by default). With max_in_flight: 1, ticks run one at a time,
and a tick that arrives during a run waits in the observer’s queue instead of being skipped.
A condition_function_id that you can state over the call becomes a filter, evaluated without an
extra function call. A condition that let only gold customers through becomes
filters: { "input.customer_tier": { $eq: "gold" } }; see the
filter language. A condition that needs code, such as a lookup or an auth
check, moves to the start of the target.
Any function is observable: iii.observe("orders::create", "alerts::big-order") runs
alerts::big-order after every successful call of orders::create, with no event source in
between. See Observers.
Move a trigger type to an observable function
A trigger type becomes a function that returns the event it is called with. Its worker keeps the same jobs in hooks: set something up when an observer is added, tear it down when the observer is removed, and say which observers want each call. The engine stores the observers, validates their args, applies their filters and policy, and replays them when the worker reconnects.- Pick a function id for the event, by convention
<worker>::on-<event>, and register it with a handler that returns its input. Setprivate: true, so only your worker can make the event. - Describe the config your trigger type accepted as
args_format. Move a declaredtrigger_request_formattoargs_formatandcall_request_formattorequest_formatandresponse_format. - Move
registerTrigger(config)toonObserverAdded(obs)andunregisterTrigger(config)toonObserverRemoved(obs). Read the config fromobs.args. - Replace the loop that fired each binding with one call of your function per event. If every
observer wants every event, iii’s default delivery is enough. If observers want different events
depending on their args, say which in
onObservationMatch: a predicate, or keys when the function has many observers. - Stop registering the trigger type, and tell consumers the function id and its args.
- Run one instance of the worker for the function. A function id has one host per namespace, so the
engine refuses a second registration with
FUNCTION_NAMESPACE_CONFLICT, and the SDK does not try again. Keep the host running and let the platform restart it: the engine hands the function’s pending observers to the next worker that registers it.
timer trigger type, which calls each binding every every_ms milliseconds.
After the move, the worker runs one timer per distinct interval, however many observers share it,
and its onObservationMatch predicate gives each tick to the observers of its interval.
- Node / TypeScript
- Python
- Rust
- Go
Before (0.25)After (0.26)
registerTrigger({ type: "timer", function_id, config: { every_ms } })
observes the function:
- The engine validates
argsagainstargs_formatbefore any hook runs, and rejects an observer whose args do not match withINVALID_ARGS. To reject an observer for another reason, throwObserveRejection(code, message)fromonObserverAdded; the consumer’s observer turnserrorwith your code. - An observer becomes
activeonceonObserverAddedresolves, so the timer exists before the first tick it should see.onObserverRemovedruns exactly once for every observer that was accepted. - The SDK runs a function’s hooks one at a time, in the order the engine announced the observers.
- A worker’s call to its own private function passes the input and the result by reference. Treat both as read-only.
- To keep a resource open while anything observes any function of the worker, use the worker-wide
hooks:
iii.onObserverAdded(() => events.retain())andiii.onObserverRemoved(() => events.release()), whereeventsis that resource, such as a connection to an upstream event stream.
Move an owner-delivered function to hooks
In 0.25, a function registered withobserve: { delivery: "owner" } received its own calls, picked
observers from its own index, and handed each event over with obs.emit or obs.call. In 0.26 the
function’s handler returns the call’s value, onObservationMatch decides which observations each
call makes (which observers get it), and onObservationDeliver carries out an observation: it hands
the call to an observer and returns that observer’s answer to the caller:
The example moves
chat::on-command, a router that hands each chat command to the observer
registered for it and returns that observer’s reply. Each observer gives one command in its args
({ command }), and a second observer of the same command is rejected with COMMAND_TAKEN.
- Node / TypeScript
- Python
- Rust
- Go
Before (0.25)After (0.26)
call:
iii.call({ observer, payload }) sends the payload as given, applies the observer’s policy
(max_fires, expiry, max_in_flight), adds its metadata, and enforces the loop guard. It fails
with the target’s own error, or with OBSERVER_NOT_FOUND, NOT_FUNCTION_OWNER,
OBSERVER_NOT_ACTIVE, LOOP_GUARD, OVERFLOW, or TIMEOUT. Only the worker that hosts the
observed function may call through its observers.
An owner-delivered function that handed events to observers chosen by their args with obs.emit
moves the way the timer above does: call the
function with the event, and say which observers want it with onObservationMatch. With many
observers, give it keys ({ observer, call }), and iii keeps the index.
Observer on and deliver
In 0.26, an observer’s capture decides both the outcomes that run its target and what the payload
carries (see capture). The 0.25 source fields on and deliver
are not part of the 0.26 SDKs: observe() has no on or deliver field, so a worker moved to a
0.26 SDK must use capture instead. Workers that stay on a 0.25 SDK keep sending them, and a 0.26
engine does not refuse them: it translates them to their 0.25 behavior when it registers the observer, and logs one warn per registration that names the observer, the
fields, and the capture to use instead. An observer from a worker on a 0.25 SDK or a 0.26 release
candidate SDK always gets the 0.25 semantics, even without on or deliver. A value the engine
does not know, such as on: "bogus", is refused with INVALID_ARGS. The exact rules are in
Legacy on and deliver.
Move each observer to capture:
The default
capture is ["input", "output", "caller"], as in 0.25. The call’s metadata reaches
the target only when capture names it.
The Observed payload omits a field the call does not have instead of sending null. On 0.25, an
observer that captured output got "output": null after a successful call that reported no
output; on 0.26 that payload has no output.
From a 0.26 release candidate
- The default
captureis["input", "output", "caller"]. Earlier release candidates of this change addedmetadatato the default; addmetadatatocapturewhere a target readspayload.metadata. Route on call metadata inonObservationMatchoronObservationDeliver, which receive it whatever the observers capture, or filter onmetadata.*, which works without capturing it. - A 0.26 engine translates
onanddeliverfrom release candidate SDKs with a warning instead of refusing them withINVALID_ARGS. The 0.26 SDKs no longer have these fields onobserve(): replace them withcapturewhen you move a worker to a 0.26 SDK. - The engine and the 0.26 SDKs add the
observe.capturefeature. Observer views andengine::observers::listcarryfires_on, the outcomes that fire each observer; the numericfiresinengine::observers::listis still the number of activations. - The engine’s own observers that hand their target the bare input capture
["input"]only. - Rust code that embeds the engine library:
FunctionHandler::handle_function, theHandlerFntype, andMessage::InvokeFunctiontake the call’s metadata asOption<Arc<Value>>instead ofOption<Value>.
Checklist
- Upgrade the engine and the iii workers your project runs in one release, together with every
worker that registers triggers, trigger types, or functions with
observe: { delivery: "owner" }; other workers keep working on a 0.25 SDK until you move them - Replace
trigger,trigger_async,Trigger,TriggerRequest,TriggerAction, andiii triggerwithcall,call_async,Call,CallRequest,CallAction, andiii call - Replace every
registerTriggerwithiii.observeof the type’s function, and everyunregister()withdispose() - Replace
onanddeliverin observer sources withcapture, which decides the outcomes that fire, when you move the worker to a 0.26 SDK: the 0.26 SDKs no longer have these fields onobserve(). Workers still on a 0.25 SDK keep working, since a 0.26 engine translates the fields with a warning, but that support will be removed in a future release. See Observeronanddeliver - Read the event from the payload’s
inputin targets that expected a trigger’s event, and setcapture: ["input"]on their observers. A source that delivers its calls itself, such ashttp::on-requestorade::ui::asset, decides what its targets receive: follow its documentation instead - Move each
configurationtrigger’sconfiguration_idandevent_typesto filters oninput.idandinput.event_type - Move
console:script,console:style, andconsole:moduleregistrations to observers ofade::ui::asset - Turn each
condition_function_idintofilters, or move the check into the target - Move your own trigger types to functions with
args_format,private: true, and hooks - Run one instance of each worker that hosts a function, and let the platform restart it: a
second instance is refused the function with
FUNCTION_NAMESPACE_CONFLICTand is not retried - Move functions registered with
observe: { delivery: "owner" }toonObservationMatch,onObservationDeliver, andiii.call({ observer, payload }) - Replace
observe: { allow: false }withobservable: false - Remove the RBAC trigger keys from the engine config, and grant sessions the functions they observe
- Replace
engine::triggers::*andengine::registered-triggers::*in scripts and dashboards withengine::functions::*andengine::observers::* - Move
iii-sandboxout ofengine.workersandconfig.yaml, and removeiii-workerandiii-init, as in Upgrading to the sandbox worker
For the observer model, see Observers. For the four hooks and private
functions, see Observable functions. For the wire
protocol, see Engine protocol.