Skip to main content
This page documents the wire-level protocol the engine and SDK workers exchange. Most projects use a language SDK (Node, Python, Rust, Go, Browser) and never touch the protocol directly. The shapes below are the source of truth those SDKs serialize to.
Observability introspection (traces, logs, metrics, sampling rules, alerts, rollups) is owned end-to-end by the iii-observability worker. The engine injects this worker automatically; do not declare it in config.yaml, engine.workers, or containers.

Connection ports

The engine binds its worker and stream WebSockets. Project HTTP routes and observability use their own workers: The console UI (ADE) runs on 3113 by default. Add it to a project with iii call compose::add worker=ade.

Connection flow

A worker opens the SDK WebSocket (default ws://127.0.0.1:49134). On connect the engine assigns the worker a UUID and sends a WorkerRegistered { worker_id, reattach_token, features } frame carrying it. The worker sends the registrations it holds in memory (each RegisterFunction it intends to expose, then each Observe it owns) and calls engine::workers::register to publish its own metadata (runtime, version, OS, PID, isolation, optional namespace, an optional one-line description, and the protocol features it supports), which the engine acknowledges with a RegisterWorkerResult. See Feature negotiation for what features turns on. The engine answers every request on the worker listener (/, /otel, /ws/channels/{id}), including the WebSocket upgrade, with an x-iii-capabilities response header: optional transport capabilities as comma-separated tokens. Clients ignore tokens they do not know. tcp-nodelay means the engine’s end of the socket has TCP_NODELAY set, so the worker may disable Nagle on its end too. A worker must not disable Nagle without this token: with Nagle on the engine’s end only, a worker that answers immediately leaves the engine’s next small frame waiting for the worker’s delayed ACK (~40 ms on Linux), which is slower than keeping Nagle on both ends. Older engines send no header. The connection is bidirectional from that point on: the engine pushes InvokeFunction frames at the worker, and the worker pushes InvocationResult, additional registrations, or unregistrations back. A connection gets its namespace from the engine::workers::register call. A client can send registration messages before this call. The engine holds these messages until it knows the namespace. It does not register them in default and move them later. When engine::workers::register arrives, the engine registers the worker, sets the connection namespace, and processes the held messages in arrival order. If the registration timeout expires first, the engine sets the connection namespace to default. See Namespaces.

Channel sockets

Each end of a channel from engine::channels::create opens its own WebSocket on the worker listener: /ws/channels/{channel_id}?key={access_key}&dir=read for the reader, dir=write for the writer. The engine forwards the writer’s text and binary frames to the reader in order.
  • Clean end. The writer sends a close frame with no status code or with code 1000. The engine classifies the end when that frame arrives, so a writer that drops the socket right after sending it still ends cleanly. The reader gets the queued data, then a close frame with no status code (clients see 1005).
  • Abort. Anything else on an attached writer socket: a close frame with any other code (1001, 1011, 4000, and so on), or a connection that ends without a close frame (TCP reset, receive error). A browser client can only send 1000 or 3000-4999, so one that aborts on purpose must use a code from that upper range; a page that closes mid write sends 1001, which is also an abort. The reader gets the data queued before the abort, then a close frame with code 1011 and a short reason, such as channel writer aborted: connection lost before close. A reader waiting on a channel whose owner disconnected before any writer attached gets the same 1011 close.
  • Reader end. When the reader closes or disconnects, the engine removes the channel and closes or drops an attached writer’s socket.
A reader treats a close with code 1000 or no status code as the end of the stream, and any other close, or a connection that ends without a close frame, as an error. A reader that closed its own socket treats the end that follows as normal. After the engine sends a close frame to a reader, it waits up to 1 s for the close reply before it drops the socket.

Feature negotiation

Observers, host delivery, and local dispatch are negotiated per connection, so SDKs and engines from different releases work together.
  • Engine to worker. WorkerRegistered carries features. This engine sends ["observe.v1", "observe.v2", "observe.capture", "local-dispatch.v1"], in that order. A 0.26 release candidate sends ["observe.v1", "observe.v2", "local-dispatch.v1"], a 0.25 engine sends ["observe.v1", "local-dispatch.v1"], and engines before 0.25 omit the field.
  • Worker to engine. The worker lists the features it supports in the features array of its engine::workers::register payload. Every 0.26 SDK declares observe.v1, observe.v2, observe.capture, and local-dispatch.v1 (a 0.26 release candidate SDK declares no observe.capture). The Node, Python, Rust, and Go SDKs also declare large-messages.v1, which the engine never advertises (see the rules below). A pre-0.25 SDK sends none.
What each feature turns on: Rules:
  • The first engine::workers::register that carries a non-empty features list fixes the connection’s features. Later declarations are ignored (logged at debug). The engine keeps at most 32 names, and drops names longer than 64 bytes. A malformed list costs the worker its features, never its registration.
  • The engine pushes FunctionRegistered, ObserverAdded, and ObserverRemoved only to connections that declared observe.v1. ObserveResult and ObserverStateChanged answer a connection’s own Observe and go to it regardless. Old SDKs never receive a message type they cannot decode.
  • The engine adds caller, observe_depth, and activation to an InvokeFunction only when the receiving connection declared observe.v1.
  • The engine decodes every worker-to-engine observe message from any connection, and reads InvocationResult.matched and InvocationResult.delivered only from connections that declared observe.v2.
  • large-messages.v1 commits the connection to receiving JSON messages up to 64 MiB (67,108,864 bytes of serialized envelope), sent as one WebSocket text frame or as a fragmented message. The engine then sends it messages up to that size whatever version it reports, except to runtime: "browser" (16 MiB). Without it, the engine falls back to the reported SDK version; no other feature counts. See Payload size.
  • An SDK enables observe() only when the engine advertised observe.v1; against an older engine, observe() fails with UNSUPPORTED. An SDK runs one of its own functions in-process only after the engine confirmed that function with FunctionRegistered { local_dispatch: true }.
  • Against an engine that did not advertise observe.v2, an SDK leaves each function that declares args_format, private: true, or observable: false, or that sets an onObservationMatch or onObservationDeliver hook, unregistered, and logs UNSUPPORTED once for it. It holds that registration until the current connection’s WorkerRegistered lists observe.v2, and decides again on every reconnect, including when it replays registrations. Every other function, call, and observe work: the SDK reports matches of local calls only, sends no matched and no delivered, and sends output where it would set output_is_input, and the engine evaluates every active observer of a routed call.
  • observe.capture is negotiated on its own, not implied by observe.v2. The engine sends every observer view (FunctionRegistered, ObserverAdded) with fires_on. To a host that did not declare observe.capture (a 0.25 SDK, or a 0.26 release candidate SDK that declares observe.v2), it also sends the legacy on those outcomes imply, which is how such a host decides the outcomes. The engine reads each host’s features once per FunctionRegistered, not once per observer.
  • An Observe from a connection that did not declare observe.capture gets the 0.25 semantics, whether or not it carries on or deliver (see Legacy on and deliver).
  • Against an engine that did not advertise observe.capture (0.25, or a 0.26 release candidate), a 0.26 SDK sends each Observe with the legacy on its capture implies (error for a capture that fires on errors only, complete for one that fires on both, none for successes), on the first send and on every replay, and never sends on to an engine that advertised observe.capture. It reads observer views without fires_on the 0.25 way: the outcomes come from on (absent means success), not from capture. It logs a deprecation warning once per client: the connected engine does not support observe.capture, legacy on semantics are deprecated and will be removed in a future version, and the engine must be upgraded. Support for engines without observe.capture will be removed in a future release.
  • If the namespace grace timer drains before engine::workers::register arrives, the connection is treated as legacy until it does; the engine then sends FunctionRegistered for the functions the connection already registered. An Observe registered in that window gets the 0.25 semantics.

Message types

Every frame is a JSON object discriminated by type (the lowercased variant name, e.g. registerfunction). The full set, defined on Message in engine/src/protocol.rs:

RegisterFunction

A field that is not set is left out, and so are private: false and observable: true. The engine leaves out request_format and response_format when it sends a registration that has none. The SDK’s observer hooks stay in the worker and never travel. See Observable functions for the provider side. An event source sets the v2 fields. This cron::on-tick validates its observers’ args, may be called only by its own worker, and returns the tick it is called with, so its response_format is its request_format:
When the registration is accepted, a connection that declared observe.v1 receives FunctionRegistered.

Registrations from older SDKs

A 0.25 SDK describes observation with an observe object on RegisterFunction: { delivery?, args_format?, legacy_trigger_type?, allow? }. The engine decodes it from any connection:
  • delivery: "auto" and an absent delivery are accepted.
  • delivery: "owner", or a value the engine does not know, is refused with RegistrationRejected UNSUPPORTED, and the function is not registered.
  • allow: false registers the function as observable: false; an absent allow is true.
  • args_format and legacy_trigger_type are decoded and ignored.
No 0.26 SDK sends this object.

InvokeFunction

invocation_id is omitted on Void invocations from a caller (the caller has no result channel to reply on). metadata is any JSON value delivered to the receiving function as a distinct argument alongside the payload: the call’s metadata sidecar. null is the same as no metadata: the engine reads it as absent, and every SDK leaves the field out and hands its handler no metadata. On an activation it is the observer’s metadata, which lets a target shared by many observers recover which one fired and with what context. traceparent and baggage contain W3C trace context. action is the routing flag (see Invocation actions below); absent / null means synchronous. namespace is optional and selects the namespace function_id resolves in. Omit the field to resolve in default; omission also keeps a peer that never sends it wire compatible. Send a non-empty string when the field is present. The engine rejects null and any other non-string value. See Namespaces for the resolution rules. Private functions. The engine accepts an InvokeFunction of a function registered with private: true only from the connection that hosts it. Any other connection, and the engine itself (RBAC hooks, middleware), gets an InvocationResult error FORBIDDEN. The hosting connection’s own InvokeFunction of its private function skips the session’s RBAC middleware.

Observe fields

Three optional fields carry causality and activations. Their meaning depends on the direction: The engine adds these fields to a frame it sends only when the receiving connection declared observe.v1. It reads them leniently: a malformed value counts as absent instead of failing the call. caller.parent is one level only; a parent inside parent is not part of the format and the engine drops it. caller.worker_* cannot be forged; function_id, invocation_id, and parent are asserted by the calling SDK and are useful for routing and filtering, not for authorization. The engine still injects _caller_worker_id into data in this release. A routed call, as the engine forwards it. To an observe.v2 host, every routed call carries an invocation_id, void calls included, so the host answers each one (see Who delivers):
Activations. When iii delivers a call to an observer, the engine invokes its target with an ordinary InvokeFunction. data is the Observed payload (the top-level fields, plus what the observer’s capture names that applies to the outcome), metadata is the observer’s metadata sidecar, and caller is the engine (worker_name: "iii-engine", with the observed function and invocation). An activation carries an invocation_id and no action: the target’s SDK answers it with an InvocationResult like any call. The engine discards that result for the observed side, and delivers the call to the target’s own observers as for any routed answer (Routed answers). The answer frees the observer’s max_in_flight slot, and so does observers.activation_timeout_ms (30 s by default) for an activation never answered. The observed input never contains the engine’s _caller_worker_id stamp.

Calls through an observer

A function’s onObservationDeliver hook can run an observer’s target itself with iii.call({ observer, payload }), for example to route an HTTP request to one route’s handler and return that handler’s response. The SDK sends an InvokeFunction whose function_id is the observer’s target and whose activation carries observer_id only, without namespace or metadata. The engine routes the call to the observer’s stored target and ignores a namespace or a metadata if one is present: the target’s metadata is the observer’s. Every SDK refuses a call through an observer that names metadata before sending it. The engine checks, in order:
  1. The observer exists, else OBSERVER_NOT_FOUND.
  2. The sender hosts the observed function, else NOT_FUNCTION_OWNER.
  3. The observer is active, else OBSERVER_NOT_ACTIVE.
  4. Policy: depth (LOOP_GUARD), expiry, max_fires, then max_in_flight. A call over the observer’s max_in_flight fails at once with OVERFLOW.
It then adds the observer’s metadata, an activation_id, and observe_depth + 1, and routes the call. The call runs as an activation of the observer: the target runs under the session of the worker that created the observer, and the payload goes as given less a top-level _caller_worker_id, so the host’s identity and grants authorize nothing the target does. It is that worker’s call: a private target takes it only when that worker hosts the target. The host is the call’s caller. The deliver hook applies the observer’s capture and filters with obs.matches(call) before it calls.
caller is the http::on-request invocation the deliver hook runs in. A request/response call gets a failed check back as the InvocationResult.error of that call, and a target that does not answer within observers.activation_timeout_ms fails with TIMEOUT:
A fire-and-forget call through an observer (action: { "type": "void" }, no invocation_id) gets no answer. The engine drops a refused one, counts it in iii.observer.dropped.total with its code as the reason, and logs it at debug:
A host sends one observer something of its own, such as its initial state once obs.accepted() resolves, as a call through that observer, as above. See Send an observer its initial state.

InvocationResult

Success:
Failure:
"result": null is a function that returned null. result is absent when the function returned nothing, and on an answer that carries error. ErrorBody.code values that appear in InvocationResult.error include invocation_failed (handler threw), invocation_stopped (the hosting worker disconnected mid-flight, so the engine cancels the in-flight call and surfaces this code to the caller), function_not_found, function_not_invokable, TIMEOUT (client-side timeout), and FORBIDDEN (RBAC denial, or a call to a private function from a connection that does not host it). A call through an observer can also fail with the codes in Calls through an observer. A function_not_found message names the namespace the lookup ran in, and lists the namespaces where the id does exist: Function state::get not found in namespace default. It is registered in namespace(s): orders, analytics.

Routed answers

An observe.v2 host’s answer to a routed call says how the call is delivered. The engine evaluates the observers’ capture and filters itself, with the input it kept when it forwarded the call, the answer’s result or error, the caller it forwarded the call with, and the call’s metadata. The host runs what is worker code, the function’s match and deliver hooks, and its answer carries one of three things: The answer for a function with a match hook:
The answer for a function with a deliver hook:
  • An answer for a function without hooks is the plain success or failure above: an absent matched leaves every observer to the engine. A function with a match hook and no deliver hook always answers with matched, [] included. A host sends at most one of matched and delivered; with both, delivered wins.
  • An answer too large to send is answered with the error payload_too_large. The error is not the handler’s outcome, so it activates no observer: it carries delivered: true when the deliver hook delivered the call, and matched: [] otherwise, whatever the function’s hooks. A host whose engine lacks observe.v2 sends the bare error. The caller gets payload_too_large either way.
  • matched may ride an answer that carries error: iii’s delivery rethrows the handler’s error, and the payload’s status and error come from the answer.
  • The host names the observers of a routed call on its answer only. An InvocationsObserved item reports a local call.
  • The engine reads matched and delivered only from observe.v2 connections, on every routed answer, a target’s answer to an activation or to a call through an observer included, and strips both before it forwards the result to the caller.
  • Only the function’s host answers for its observers. An answer from an observe.v2 connection that does not host the function (a caller answering its own call by its invocation_id, say) activates nothing and is not evaluated, whatever it carries: each observer its matched names counts a REPORTER_NOT_OWNER drop, and without matched (neither field, or delivered) every active observer of the function does. The caller gets the answer’s result.
  • A routed call whose answer never comes (its caller left, the host went away) activates nothing: each of the function’s observers counts a CANCELLED drop.
  • The payload’s input is the input the engine routed, and its output is result. Delivery costs no frame beyond the answer. The engine matches the filters through an index of their $eq and $in conditions, and evaluates every filter where the lookups would spare little, so a call costs at most about the evaluation of every filter plus one lookup per indexed path, and about one lookup when many observers filter on their own value of one path (see Filters).

RegistrationRejected

The engine sends this message when it refuses a registration. Each message contains one identity field. For a conflict with a live worker in namespace, owner_worker_id identifies the worker that owns the identity; on every other rejection it is "". code specifies the cause and the severity: A function id refused because it is reserved also arrives as FUNCTION_NAMESPACE_CONFLICT. No worker owns it, so owner_worker_id is engine (reserved prefix). A function conflict has this format:

Function conflict behavior

Worker registration and function registration are separate operations. The engine can accept a worker and reject one of its functions. A function id has one host per namespace, so a function’s observer hooks run once per observer, in that host, however many instances of the worker run. For a function ownership conflict, the engine:
  1. Keeps the current function host.
  2. Does not register the new handler.
  3. Sends FUNCTION_NAMESPACE_CONFLICT to the new worker.
  4. Keeps the new worker connection open.
  5. Continues to register and serve the new worker’s other functions.
FUNCTION_NAMESPACE_CONFLICT is a registration result. It is not an invocation result. A later invocation of the same function id in the same namespace goes to the current host. It does not go to the worker whose registration was rejected. The SDK does not register a refused function again. When the host disconnects, the function’s observers go pending; the worker that registers the function next receives them in FunctionRegistered, runs its hooks for each, and they become active again.
A connected worker does not confirm that all its functions are registered. The SDK reports a function conflict as a warning and keeps the worker active. If the worker requires all its functions, treat this warning as a startup or deployment error.
A worker restarting against its own not-yet-cleaned connection is not a conflict: the engine treats a connection that has begun tearing down as not live, so the restart reclaims its name immediately.

Invocation actions

InvokeFunction.action is tagged by type and lowercase-encoded on the wire. The SDKs build it with CallAction:

Invocation lifecycle

For synchronous calls the engine assigns an invocation_id, forwards the InvokeFunction to the hosting worker, and waits for the matching InvocationResult. For Void actions the caller gets no reply: the engine forwards the call without an invocation_id, except to an observe.v2 host, which gets one and answers with the observers its delivery matched; the engine activates them and drops the result. For Enqueue the engine hands the invocation to the queue worker, which persists it and invokes the target function according to the queue’s retry policy.

Observe messages

Every function is observable. An observer is what iii.observe(source, target, options) creates: a function with its args and filters, a target function, and a policy. When the function runs, the target runs. The observing worker is the connection that sent Observe; the host is the connection that hosts the observed function. For the model, see Observers; for the host side, see Observable functions; for the filter language, see Filters.

Wire rules

  • Optional means absent. An optional field that is not set is left out, never sent as null. Decoders accept null for it anyway. In InvocationsObserved items, input, output, error, and metadata keep null apart from absent: "output": null is a function that returned null, while a missing output was not captured, does not exist, or equals input ("output_is_input": true).
  • Enums are lowercase strings. capture[]: input | output | error | caller | metadata. state: pending | active | suspended | error | disposed. status: success | error. A decoder keeps a value it does not know instead of failing the frame.
  • Defaults. Absent capture is ["input", "output", "caller"], and absent observe_depth is 0. A field at its default is left out when sending: observe_depth: 0, private: false and observable: true on RegisterFunction, observed: [] on InvocationResult, and output_is_input: false on InvocationsObserved items.
  • capture decides the outcomes. input and output fire the target on success, error on error; caller and metadata only add to the payload. A non-empty capture that names none of input, output, and error gets ObserveResult with error INVALID_ARGS. capture: [] fires on success, and the target then gets ids, status, and timings only. A host applies the same rule to the calls it evaluates, reading the outcomes from the view’s fires_on. These rules apply to an Observe from a connection that declared observe.capture and that carries neither on nor deliver; every other Observe follows Legacy on and deliver.
  • Engine-owned observers capture input only. An observer the engine creates for itself and that hands its target the bare input (the configuration and stream bridges, telemetry on queue::on-message, observability on ade::ui::asset) keeps and announces capture: ["input"], so a host reports no output, caller, or metadata for it.
  • Unknown values are refused, never defaulted. An Observe with an unknown capture[] value gets ObserveResult with error UNSUPPORTED (the message names the field). A RegisterFunction whose args_format is not a valid JSON Schema gets RegistrationRejected INVALID_ARGS_FORMAT.
  • Ids are strings. Observer ids are client-generated UUID v4 strings, stable across replays.
  • Malformed frames. The engine reads caller, observe_depth, and activation on InvokeFunction, and matched and delivered on InvocationResult, leniently: a bad caller, observe_depth, or activation counts as absent, a matched that is neither an array nor null counts as [] (a non-string id in it is skipped), delivered counts only when it is true, and the rest of the frame is handled. An Observe the engine cannot decode but whose id is a string gets ObserveResult with error INVALID_ARGS.
  • Revisions. revision counts announcements per (namespace, function_id) and grows by one with each: FunctionRegistered, every ObserverAdded, and every ObserverRemoved. An SDK ignores an ObserverAdded or ObserverRemoved whose revision is not greater than the one it holds on the same connection. FunctionRegistered is always accepted and replaces the set (revisions restart after an engine restart).
  • Normal form. The filters the engine pushes to a host are the observer’s filters in normal form: {} matches every call, anything else is {"$and": [...]} at every level. Compile them as given. See Filters.
  • What hosts see. A host receives an observer’s id, args ({} when none), normalized filters, target function id (its namespace is not disclosed), capture, and fires_on: the outcomes that fire the observer, ["success"], ["error"], or ["success", "error"]. A host without observe.capture also receives on ("error" or "complete"; absent means success). It never receives the observer’s metadata, its policy, or the observing worker. capture alone does not tell the outcomes: a translated legacy observer can capture ["error"] and fire on success.

Who delivers

The connection that hosts a function runs the function’s hooks when it declared observe.v2. Its SDK runs the handler, then matches observers to the call: every active observer by default, or those the function’s onObservationMatch hook selects by predicate or by keys. iii’s delivery then selects the matching observers whose capture and filters match the call. A function’s onObservationDeliver hook takes delivery over: it receives the matching observers and calls through them itself. Where the filters run depends on the call:
  • Local call. The host called its own function, and the engine never sees the call. The host evaluates capture and filters and sends the observers delivered in a batched InvocationsObserved report. Nothing is sent when nothing matches, or when the deliver hook delivered the call.
  • Routed call. The engine routed the call to the host with an invocation_id, and holds its input, caller, and metadata. The host’s answer names the observers the match hook matched, or says that the deliver hook delivered the call, and the engine evaluates capture and filters (Routed answers).
  • Through an observer. The host runs one observer’s target itself with an InvokeFunction that carries activation.observer_id (see Calls through an observer).
The engine evaluates every active observer of a routed call to a host without observe.v2, of a call to an engine-hosted function, and of a call to a function registered with invocation (HTTP-invoked: the engine calls the endpoint, so no connection answers).

Observe

Observing worker -> engine. Creates an observer owned by the sending connection, or replays it idempotently (same id, same spec).
The minimal form names only the two functions. It is the frame iii.observe('orders::archive', 'search::reindex-order') sends:
At creation the engine checks RBAC (the session’s grants must cover both the observed function and the target, whether or not the observed function is private, and the on_observer_registration_function_id hook may rewrite or deny), that the function is observable (NOT_OBSERVABLE), args, filters (including filter paths against the function’s request_format / response_format), the per-connection and per-function limits, that the target is not the observed function (SELF_TARGET), and that the target is not a private function (FORBIDDEN). An Observe whose id an observer already holds is a replay when it comes from the observing connection with the same spec: the engine answers with the observer’s current state. The replay of an observer in error is refused with that error: the engine disposes the observer as it answers, sends nothing more about it, and holds nothing under the id. A different spec, or another connection, fails with OBSERVER_ID_CONFLICT, except for a worker back on a new connection. When the observer is suspended because its observing worker is away, an Observe of its id from a connection under the same worker name in the same namespace (as declared on engine::workers::register) replaces it. Any connection can declare any name, so the two connections must also be the same principal: both on ports without rbac, or both on RBAC ports where the auth function gave them the same context, one that is not empty. A session with no context replaces nothing, and a connection on a port without rbac and an RBAC session never replace each other’s observers. A restarted worker re-observes under the ids it used before this way, and a queue consumer keyed by observer id keeps its backlog. The engine runs every check above first, and replaces the suspended observer when it creates the new one: it disposes the suspended observer with OBSERVER_DISCONNECTED, sends its host nothing more about it (the host got ObserverRemoved when the worker left), and the host gets an ObserverAdded for the new observer, as for any new id. An Observe the engine refuses leaves the suspended observer holding its id. The earlier connection, reattaching, has its replay of that observer answered with disposed. The takeover needs a worker name that stays the same across restarts: III_WORKER_NAME, which a supervisor sets, or the SDK’s worker name option. The SDKs default the name to hostname:pid (the browser SDK to a random name), which changes when the process restarts, so a restarted worker under the default name gets OBSERVER_ID_CONFLICT until the grace period ends.

Legacy on and deliver

0.25 SDKs and 0.26 release candidate SDKs can send source.on and source.deliver. Both are deprecated, and support will be removed in a future release. The engine does not refuse them: it translates them when it registers the observer, on the first Observe and on every replay, to the 0.25 behavior, and logs one warn per registration that names the observer id, the fields, and the source.capture to use instead.
  • Outcomes come from on alone. Absent or success fires the target after a successful call, error after a failed one, and complete after both. capture does not change the outcomes, and the rule that a capture must name input, output, or error does not apply: capture: ["caller"] is accepted and fires on success.
  • The payload carries the capture list exactly. An absent capture is ["input", "output", "caller"].
  • deliver picks the payload. Absent or envelope: the Observed payload. input: the call’s input alone, null when the call has none. output: the call’s output alone, null when it has none, so the target gets null after every failed call.
  • Invalid values are refused. An on or deliver value the engine does not know, or one that is not a string, gets ObserveResult with error INVALID_ARGS. null counts as absent.
  • Replays are stable. The translation is part of the observer’s spec: a replay with the same fields is idempotent, and one that changes them fails with OBSERVER_ID_CONFLICT.
  • Connections without observe.capture. An Observe from a connection that did not declare observe.capture (a 0.25 SDK, or a 0.26 release candidate SDK) gets these semantics even when it carries neither on nor deliver. Its outcomes come from on, so capture: ["error"] without on fires on success, as it did on 0.25. When the source carries neither field, the one warn per registration says that the worker does not declare observe.capture and names the source.capture to use after upgrading the SDK.
The translated payload differs from 0.25 in one way: the Observed payload omits a field it does not have instead of sending it as 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.

Unobserve

Observing worker -> engine. Disposes an observer this connection owns.

ObserveResult

Engine -> observing worker. The reply to Observe (also to a replay): the observer’s current state.
A rejected observer carries state: error and the error. warnings lists filter paths the engine could not verify against the function’s schemas (an open schema, a remote $ref, several anyOf branches):

ObserverStateChanged

Engine -> observing worker. Every lifecycle transition. fires is always sent; reason explains error, suspended, and disposed states.
States:

Answer order and observer ids

The engine reads one connection’s messages in order and sends that connection its observe answers (ObserveResult, ObserverStateChanged) in the order it makes them. It answers every Observe with exactly one ObserveResult, and sends ObserveResult for nothing else. An id therefore names one observer at a time on the wire, and an SDK can observe again under an id as soon as it has unobserved the earlier observer of that id, or received its disposed. It tells the two observers’ answers apart by order:
  • It keeps, per id, the observers whose Observe it sent on the current connection, oldest first. A disposed one stays listed until its disposed arrives, a newer observer of the id has its ObserveResult, or the connection closes.
  • An ObserveResult for the id answers the oldest listed observer that has none yet.
  • An ObserverStateChanged for the id is about the newest listed observer that has its ObserveResult. One that arrives before any has it is dropped: a reattached connection hears its observers resume before its replayed Observes are answered, and each replay’s ObserveResult carries the current state.
  • On a new connection, the Unobserves queued while disconnected go out before any replayed Observe, so the engine disposes the earlier observer before it reads the new one’s Observe.
  • An ObserveResult that refuses the Observe (state: "error") is that observer’s last answer, and the observer leaves the list when it arrives. Disposing a refused observer sends nothing. A dispose whose Unobserve went out before the refusal arrived ends when the refusal arrives.
The engine’s disposed for the earlier observer, sent before it read the new Observe, reaches the earlier observer’s handle, and the new handle gets its own answers only. The engine stores nothing for a refused Observe, and ignores an Unobserve of an id that none of the connection’s observers holds: nothing answers it, and another connection’s observer of that id stays as it was. An SDK refuses an observe whose id one of its live handles holds, with OBSERVER_ID_CONFLICT and nothing sent. An observer the engine disposes (max_fires, expiry, an admin) frees its id on a connection that heard its disposed: one that had the observer’s ObserveResult before the ObserverStateChanged that disposed it. That connection may observe under the id again at once. On a connection that reattached, the engine answers the first Observe of that id with disposed and its reason when the disposal came at most observers.observer_grace_ms before the earlier connection closed, or while it was away: it reads the Observe as a replay whose disposed was lost with the earlier connection, and does not create the observer again with new counters. A Reattach later than the grace period gets the same answer, within observers.disposed_retention_ms. After that answer, or an Unobserve of the id from the connection, the id is free there. An SDK uses that free id when the engine disposed the observer because its worker stayed away past observers.observer_grace_ms. When the answer to a replayed Observe, or an ObserverStateChanged, is disposed with OBSERVER_DISCONNECTED and the observer has neither max_fires nor expires_in_ms, the SDK sends a new Observe with the same id, source, target, metadata and options on the same connection, and the engine creates a new observer with its counters at 0. The handle keeps its id, goes to pending without reporting disposed, and takes the new ObserveResult. The SDK does not observe again an observer with either limit (it becomes disposed, so the limits hold across the absence), one whose dispose has started, or one it already observed again on this connection (a second such disposed disposes it). This is SDK behavior: the engine’s answers are the ones above.

FunctionRegistered

Engine -> host (only to connections that declared observe.v1). The success ack of RegisterFunction, sent once per registration and again after a re-registration. It carries the function’s full current observer set and whether the SDK may run the function in-process.
  • observers is sent even when empty. It lists the function’s observers except those whose observing worker is away (suspended with OBSERVER_DISCONNECTED): each of those comes in its own ObserverAdded when the worker reattaches.
  • local_dispatch is true when the engine’s observers.local_dispatch is on, the connection may invoke the function itself, and the session has no RBAC middleware or hosts the function privately. Only then may the SDK run its own calls to the function in-process.
  • The host must ack every observer listed, each with this message’s revision, including the ones it already held before a re-registration: a new registration starts a new host relationship. The message replaces the set of observers the host holds for the function. An entry already held with the same args is re-acked without running hooks; a new entry runs onObserverAdded and is acked once it resolves; an entry whose args changed counts as removed, then added; a held observer the message does not list is removed. A new host that takes over the function holds none of its observers, so it runs onObserverAdded for each entry and acks it the same way. Until it is acked an observer stays pending. When it is not acked within observers.owner_ack_timeout_ms (30 s by default) of the host moving on to it (the host runs a function’s hooks one at a time, in announcement order), it fails with OWNER_TIMEOUT.

ObserverAdded

Engine -> host (only to connections that declared observe.v1). One observer attached to a function this connection hosts: a new observer, or one whose observing worker reattached within observers.observer_grace_ms (in the order the worker created its observers). It waits for an ObserverAck.
The engine always sends capture resolved: an observer that named none gets the default. Every view carries fires_on; a view sent to a host without observe.capture also carries the legacy on when the observer fires on errors ("error") or on both outcomes ("complete"). A view from an engine without observe.capture has no fires_on, and a 0.26 SDK then reads the outcomes from on.

ObserverRemoved

Engine -> host (only to connections that declared observe.v1). One observer detached: Unobserve, disposal, its observing worker disconnecting, or a late rejection.

ObserverAck

Host -> engine. Accepts one announced observer, echoing function_id, namespace, id, and the revision of the message that announced it (ObserverAdded, or FunctionRegistered). The SDK sends it once every onObserverAdded hook that applies to the observer has resolved (the worker’s hook, then the function’s), so whatever the hooks set up is in place before the engine marks the observer active.
With error, the ack rejects the observer, which turns error with that reason. The code is the one the hook threw with ObserveRejection (here INVALID_CRON_EXPRESSION), OBSERVE_REJECTED when the hook threw any other error, or OWNER_TIMEOUT for a hook that ran past 30 s:
  • namespace is optional; absent means the sending connection’s namespace.
  • An ack whose revision is older than the announcement is ignored as stale.
  • Late rejection. A host may send an ack with error for an observer that is already active (obs.reject(code, message), for example after a configuration reload removed what it observed). The engine moves the observer to error and sends ObserverRemoved back to the host.
  • The host never changes an observer’s filters.

InvocationsObserved

Host -> engine. Batched reports of the host’s local calls: calls of its own functions that it made itself. SDKs flush at 256 items or after 5 ms, whichever comes first, and split a flush to stay under the 16 MiB frame limit.
The engine checks the items one by one:
  • The reporting connection must host the function, else the item is dropped as REPORTER_NOT_OWNER.
  • An item that does not decode, whose status is neither success nor error, or whose invocation_id is a routed call the engine forwarded to that connection, is dropped alone (counted as INVALID_REPORT), and the rest of the batch is applied.
  • matched ids that are not active observers of that function are dropped as INACTIVE.
  • The engine does not evaluate filters on reports: the host is authoritative for its own function’s calls.
Reports have backpressure. When an item finds an observer’s queue full (OVERFLOW), the engine stops reading from the reporting connection until that queue is back to half of observers.max_queue_per_observer and the queues together are under half of their 64 MiB, the observer leaves active, or 250 ms pass. A worker that reports faster than the targets take is slowed by its own socket, and its SDK drops the excess from its bounded report buffer (BUFFER_FULL), so the engine does not spend its CPU decoding reports it would drop. Everything else the worker sends waits behind the report meanwhile, so the holds are paced: after holding a connection, the engine reads it for at least a quarter as long before it may hold it again, and drops the items that overflow meanwhile (OVERFLOW). A connection is held at most 250 ms at a time and at most four fifths of the time, however long its reports keep overflowing. The engine does not wait for an observer whose target the reporting connection hosts: the answers that would free the queue come in on that connection.

LocalInvocationStats

Host -> engine. Every 10 s, only when non-zero: per-function counts of local invocations (which feed the engine’s invocation metrics) and SDK-side report drops.
Senders always send functions[].errors and functions[].duration_ms_sum, 0 included; a decoder reads an absent one as 0, and an absent array as empty. The engine ignores entries for functions the connection does not host. dropped_reports[].reason is BUFFER_FULL or TOO_LARGE; any other reason is counted as OTHER.

Error codes

ObserveResult.error.code and ObserverStateChanged.reason.code on a rejected observer: A call through an observer fails with OBSERVER_NOT_FOUND, NOT_FUNCTION_OWNER, OBSERVER_NOT_ACTIVE, LOOP_GUARD, OVERFLOW, TIMEOUT, or the target’s own error. State reasons (ObserverStateChanged.reason.code): Drop reasons, counted per observed function in iii.observer.dropped.total (never silent; the metric is updated once a second):

Namespaces

A namespace is a routing value carried with a function id. It is not part of the function name. For example, state::get has the same id in every namespace. Registries are keyed by (namespace, function_id) and (namespace, worker_name), so the same id or worker name may appear once per namespace. A worker declares its namespace on the engine::workers::register call (RegisterWorkerInput.namespace). A connection that declares none lands in default.

Resolution

Invocation routing is strict. It never falls back to another namespace: The SDKs fill the field from the worker namespace when the caller sets none on the invocation, so the frame carries the worker’s own namespace, or default when the worker declared none. A miss returns function_not_found naming the namespaces where the id does exist. Introspection resolution is deliberately looser, so that an engine whose every worker is namespaced can still answer questions about itself. For engine::functions::info and engine::workers::info with no explicit namespace: a default entry wins; otherwise an id or name unique to one non-default namespace resolves; an id or name present in several non-default namespaces at once is reported as an ambiguity naming the candidates, never resolved by guessing. Passing an explicit namespace restores strict resolution.

Reserved ids

engine::* is reserved for engine infrastructure, which is registered in default. A custom worker that tries to register an engine::* function id in another namespace has that registration refused. The queue worker supplied with the engine registers its engine::queue::* functions in default. The reserved-id check does not reject these functions. sandbox::* is reserved in default for the engine’s sandbox router. A sandbox adapter registers its own <adapter>::* ids. A custom worker in default that tries to register a sandbox::* function id has that registration refused.

Wire compatibility

Every namespace field is optional and omitted when unset. A worker built against an older SDK sends no namespace, lands in default, and behaves exactly as before. An explicit null is not the same as an absent field: the engine rejects it, as it rejects any non-string value.

Engine discovery functions

The engine registers a set of functions under the engine::* prefix for introspection and worker lifecycle. Defined in engine/src/workers/engine_fn/mod.rs: Every row returned by engine::functions::list, engine::functions::info, engine::workers::list, and engine::workers::info carries a namespace field naming the registry key under which the entry is registered. It distinguishes two rows that share a function_id or a worker name. Neither list function takes a namespace filter; both return every namespace.

Engine-hosted sources

The engine hosts event sources as functions in the default namespace. Each is a private function whose handler returns its event, so a call’s input and output are both the event. They take no args: observe them with filters on the event. Only the engine calls them; a call from a worker fails with FORBIDDEN. A call of an engine-hosted source has caller { worker_name: "iii-engine" }, so a payload that captures caller carries it and a filter on caller.worker_name sees iii-engine. The activation’s own caller is the engine with the source’s function_id and invocation_id. When an observer names no namespace, the SDK resolves engine::* and configuration::* in default and every other function in the observing worker’s namespace. A worker in another namespace names namespace: "default" to observe observability::* or stream::*. See Engine-hosted event sources.

Engine-collected metrics

These metrics are emitted by the engine regardless of which language SDK a worker uses. Names and units come from engine/src/workers/observability/metrics.rs.

Invocations

Since 0.25 a worker runs calls to its own functions in-process (local dispatch), so the engine does not route them. SDKs report their counts in LocalInvocationStats, and the engine adds them to iii.invocations.total with the labels function_id, status (ok or error), and dispatch="local". Local invocations have no engine-side iii.invocation.duration sample.

Observers

Labelled per observed function, not per observer, to bound cardinality.

Workers

Per-worker