Skip to main content

Telo Controller Protocol Specification (v1.0)

0. Status, scope, and how to read this​

This is a runtime conformance specification. It defines the controller protocol: the complete set of operations that cross between a Telo kernel and a Telo controller, the direction each travels, what each carries, and the two carriers those operations ride — framed messages over a dedicated channel out of process, and the C ABI in process.

It is normative because a controller is an artifact published independently of every kernel that will ever load it. A controller written against one kernel's source is bound to that kernel's refactoring history; a controller written against this document runs unchanged on any kernel that conforms to it, in any language. Nothing in a manifest shows which operations a controller reached for, so a divergence here is invisible until a published module meets a second runtime.

The key words MUST, MUST NOT, REQUIRED, SHOULD, SHOULD NOT, MAY, and RECOMMENDED are to be interpreted as described in RFC 2119.

0.1 The boundary this binds​

The spec's normative force is scoped to the controller boundary — what crosses between a kernel and a controller — and not to a kernel's internals. A kernel that hosts controllers in its own process, calling them through its own language's function calls, satisfies this specification by carrying the same operations with the same meanings; it is not required to re-express its internals as messages, and no wording here obliges an existing kernel to change how it calls a controller it already hosts. What this document fixes is the set of operations, their directions, their payloads and their failures — so that a controller needing one of them behaves identically whichever kernel and whichever carrier it meets.

In scope — one section per class of transfer:

  • 4. Session — establishing a pairing and scoping every later message to one kernel instance.
  • 5. Lifecycle — the capability entry points that build, start and tear an instance down, and the revertible effects they return.
  • 6. Invocation — dispatch in both directions, and the per-call concerns that ride with it: cancellation, zones, spans, synchronous calls.
  • 7. Resolution & schema — turning what a manifest says into what a controller can use: references, schemas, and compiled values.
  • 8. Channels — flow-controlled byte streams, which is how anything too large or too long-lived to be one payload crosses.
  • 9. Host facilities — what a controller reads from the runtime hosting it: logging, child manifests, module files, the environment.

Out of scope, deliberately:

  • The kernel's own module registry. Module loading, kind and definition registration, import wiring, child evaluation contexts and manifest loading name no message in this protocol. Those are things a kernel performs natively over its own state, and no kernel ever runs inside a controller host — so expressing them as messages would oblige a second implementation to drive the first kernel's registry over a wire in order to be conformant. Resource lookup by name is reachable as dispatch (§6.2) and as reference resolution (§7.1); the registry behind it is not.
  • Controller selection. Which PURL candidate a kind's controller resolves to, where its bytes come from, and how they are verified, are the kernel's and the artifact spec's (kernel/specs/module-artifact.md). The protocol begins once a controller has been obtained (§5.1).
  • CEL. A controller never evaluates a Telo expression. Compiled values are kernel-owned and are expanded by asking (§7.4).
  • What a controller does between its entry points. The protocol says what crosses, not how a controller implements the work.

0.2 A controller that cannot be hosted​

The message set is closed at a generation. A controller that needs an operation this protocol does not name cannot be hosted, and a kernel MUST refuse the pairing rather than degrade it: the refusal is raised at Controller.Create with ERR_CONTROLLER_HOST_INCOMPATIBLE, naming the controller, the kind and the operation. There is no third error code for this — an incompatible controller and an incompatible peer are the same fact, discovered at different moments, and a second code would only invite a caller to handle one and not the other.

0.3 Generation telo-6​

One generation counter covers both carriers, reported in the handshake (§4.1) and written into a native controller's PURL as abi=telo-<n>.

  • Generation 3 is the prior, unspecified shape: JSON payloads in TeloBuf buffers across the C ABI, described only by the ABI crate's own layout. This specification does not retroactively describe it, and a generation 3 controller keeps loading exactly as it does today.
  • Generations 4 and 5 are withdrawn, and nothing ever spoke either. 4 was the first generation this specification defined, and its value encoding named the typed frame's millisecond timestamp payload. 5 moved the value encoding to durable-execution.md §6's typed frame. No carrier implemented either and no artifact could declare one, so each is superseded rather than kept readable.
  • Generation 6 is the generation this specification defines. It is 5's message set plus the two need operations (§7.5), with the same value encoding: durable-execution.md §6's typed frame, whose timestamp payload is RFC 3339 with a trimmed nanosecond fraction. It is the value in sdk/controller-protocol/generation.json, and every Session.Hello a conformance vector carries states it.
  • No artifact may declare abi=telo-6 until the ABI carrier lands. A declaration reaching a kernel that does not implement the carrier MUST be refused with ERR_CONTROLLER_HOST_INCOMPATIBLE. Until then the ABI crate's TELO_ABI_VERSION stays 3, which is what keeps the published abi=telo-3 controller candidates valid: specifying 6 does not retire 3.

This one says how an operation crosses. What the operation MEANS is owned elsewhere, and this document does not restate it: kernel/specs/revertible-effects.md (frames, LIFO recovery, disposal), kernel/specs/invocation-contract.md (resolution, binding, default-fill, validation), kernel/specs/execution-zones.md (matching, correlation, the payload rule), kernel/specs/logging.md (the record model, severity, sinks), kernel/specs/tracing.md (spans and their export), kernel/specs/durable-execution.md (the replay contract, and §6's typed frame, which is this protocol's value encoding). The authoring guide is docs/extend/controller-protocol.md.

1. The protocol​

1.1 What a message is​

A message is one operation of the controller contract: either a capability entry point the kernel calls on a controller, or an operation a controller may ask of the kernel while inside one. The set is the closure of that contract over the capability vocabulary — Telo.Service, Telo.Runnable, Telo.Invocable, Telo.Provider, Telo.Mount, Telo.Sink, Telo.Callable — and the operations the normative specifications in §0.4 already define, plus the host-provided facilities a controller reads.

A message is not a member of any one language's context interface. That derivation was considered and rejected: it pins the protocol's generation to a refactoring history, and it cannot see its own subject — invoke is two operations with opposite directions, the kernel calling a controller's entry point and a controller asking the kernel to dispatch, which a member census sees as one. They are Controller.Invoke and Dispatch.Invoke here, and they are distinct messages.

Each message is named <Group>.<Operation> and carries, as data in sdk/controller-protocol/messages/<kebab-name>.json, its direction, the spec section it realises, its request and response schemas, the protocol error codes it may carry, and whether it is synchronous and reentrant. That directory's README states its own contract; this document and that directory are two halves of one artifact, kept in agreement by pnpm run check:controller-protocol.

1.2 The two ends, and the two carriers​

The protocol's two ends are the kernel and the controller. They are never the kernel and a host: a host process is a property of one carrier, and the other carrier has no host process at all. Every message declares its direction as kernel-to-controller, controller-to-kernel or either, read against those two ends.

Two carriers deliver them:

  • Framed (§3) — length-prefixed binary frames over a dedicated bidirectional channel, never the process's own standard input or output. This is how a kernel in one language reaches controllers written in another, and one carrier instance serves every kernel instance in a process, which is why a session id rides every envelope.
  • ABI — the C ABI, in process. The same messages, the same payloads, delivered through vtable slots rather than frames.

No message exists on one carrier only. That is a property of the message set as a whole, not a per-message field, and it is what makes the ABI's next generation a carriage of this protocol rather than a second contract. Its proof is sdk/controller-protocol/vectors/carrier-equivalence.json (§11).

1.3 What crosses, and what stays​

  • Values cross as typed frames — the JSON form defined by kernel/specs/durable-execution.md §6, already normative in both languages. A runtime MUST NOT invent a second value encoding for this protocol. Every schema in messages/ that describes a value describes the frame's decoded value, so a bytes field is a value of CEL type bytes and not a base64 string a reader has to know to decode. A value of a value type declaring members crosses tagged with its type's name and its plain encoding's text — a Telo.Url as {"$telo":"Telo.Url","value":"<url-text>"} — so the reader holds a URL whose members it reads, never a string; a reader refuses a tag it does not know by name, and the tag set needs no generation of its own.
  • Instances cross by handle: a kernel-minted ResourceInstanceId and the reference identity it was resolved from. Only the id is compared; the reference identity is for diagnostics. A handle is valid for the lifetime of the live instance and within the session that minted it.
  • Anything carrying code is owned by the side that created it, and the other side holds a handle. A compiled CEL value is kernel-owned and is expanded by asking (§7.4). An effect body and the chain it sits in are controller-owned: the kernel orders and reverts them by handle and never looks inside one (§5.4). A durable run handle does not cross at all — the kernel carries it and never calls it, so a second runtime threads a handle it owns rather than deserializing one.
  • Streams, standard input and output, and a child manifest's output cross as flow-controlled channels (§8), never as one payload.
  • A resolved reference is live in the same realm where both ends of the reference are bound in one realm; otherwise it is a proxy exposing exactly the target kind's capability entry points and nothing else (§7.1).
  • Errors cross as structured codes (§10). Suspension and cancellation are distinct signals, not errors: ERR_DURABLE_SUSPENDED and ERR_INVOKE_CANCELLED are re-raised by anything that catches, on both ends.

1.4 Handles, and who releases them​

Every handle in this protocol is minted by one side and released by a message the other side sends, or by the lifetime of something already handled:

HandleMinted byReleased by
sessionkernelSession.Close
controllercontrollerthe session
instancekernelController.Destroy
chaincontrollerthe frame that executed it
effectkernelEffect.Revert or Effect.Dispose
inversecontrollerthe effect that carries it
holdkernelHold.Release
cancellation sourcekernelCancellation.Dispose
spankernelSpan.Settle
validatorkernelthe instance that compiled it
detached taskkernelDispatch.DetachSettle
channelthe openerChannel.Close
child runkernelRuntime.Exited

A handle a peer does not recognise is a protocol violation, terminal for the carrier instance (§3.5). A runtime MUST NOT silently ignore one: a stale instance handle reaching Controller.Invoke is a kernel that lost track of a teardown, and answering it would run work against an instance that no longer exists.

2. Message inventory​

One row per message, in the lexical order of the file names in sdk/controller-protocol/messages/, compared as UTF-8 bytes.

MessageDirectionSectionSynchronousReentrant
Callable.Callkernel-to-controller6. Invocationyesyes
Cancellation.Cancelcontroller-to-kernel6. Invocationnono
Cancellation.Createcontroller-to-kernel6. Invocationnono
Cancellation.Disposecontroller-to-kernel6. Invocationnono
Cancellation.Signalkernel-to-controller6. Invocationnono
Channel.Closeeither8. Channelsnono
Channel.Crediteither8. Channelsnono
Channel.Dataeither8. Channelsnono
Channel.Openeither8. Channelsnono
Controller.Bindkernel-to-controller5. Lifecyclenono
Controller.Createkernel-to-controller5. Lifecyclenoyes
Controller.Destroykernel-to-controller5. Lifecyclenoyes
Controller.Initkernel-to-controller5. Lifecyclenoyes
Controller.Invokekernel-to-controller6. Invocationnoyes
Controller.Providekernel-to-controller5. Lifecyclenoyes
Controller.Runkernel-to-controller5. Lifecyclenoyes
Controller.SinkClosekernel-to-controller5. Lifecyclenono
Controller.SinkFlushSynckernel-to-controller5. Lifecycleyesno
Controller.SinkFlushkernel-to-controller5. Lifecyclenono
Controller.SinkWritekernel-to-controller5. Lifecyclenono
Controller.Snapshotkernel-to-controller5. Lifecyclenoyes
Dispatch.DetachSettlecontroller-to-kernel6. Invocationnono
Dispatch.Detachcontroller-to-kernel6. Invocationnono
Dispatch.InvokeResolvedcontroller-to-kernel6. Invocationnoyes
Dispatch.Invokecontroller-to-kernel6. Invocationnoyes
Dispatch.Runcontroller-to-kernel6. Invocationnoyes
Effect.Disposecontroller-to-kernel5. Lifecyclenoyes
Effect.Performkernel-to-controller5. Lifecyclenoyes
Effect.Registercontroller-to-kernel5. Lifecyclenono
Effect.Revertkernel-to-controller5. Lifecyclenoyes
Effect.Runcontroller-to-kernel5. Lifecyclenoyes
Env.Readcontroller-to-kernel9. Host facilitiesyesno
Event.Emitcontroller-to-kernel5. Lifecyclenono
Hold.Acquirecontroller-to-kernel5. Lifecyclenono
Hold.Releasecontroller-to-kernel5. Lifecyclenono
Log.Dropcontroller-to-kernel9. Host facilitiesnono
Log.LevelForcontroller-to-kernel9. Host facilitiesyesno
Log.Thresholdkernel-to-controller9. Host facilitiesnono
Log.Writecontroller-to-kernel9. Host facilitiesnono
Module.ControllerFilecontroller-to-kernel9. Host facilitiesnono
Module.Filecontroller-to-kernel9. Host facilitiesnono
Native.Filecontroller-to-kernel9. Host facilitiesnono
Need.Resolvecontroller-to-kernel7. Resolution & schemayesno
Need.Verifycontroller-to-kernel7. Resolution & schemayesno
Ref.Declarationcontroller-to-kernel7. Resolution & schemayesno
Ref.Ensurecontroller-to-kernel7. Resolution & schemanono
Ref.Resolvecontroller-to-kernel7. Resolution & schemayesno
Runtime.Cancelcontroller-to-kernel9. Host facilitiesnono
Runtime.Checkcontroller-to-kernel9. Host facilitiesnoyes
Runtime.Exitedkernel-to-controller9. Host facilitiesnono
Runtime.Runcontroller-to-kernel9. Host facilitiesnoyes
Runtime.Startedkernel-to-controller9. Host facilitiesnono
Schema.Checkcontroller-to-kernel7. Resolution & schemayesno
Schema.Compilecontroller-to-kernel7. Resolution & schemanono
Schema.Resolvecontroller-to-kernel7. Resolution & schemayesno
Schema.Validatecontroller-to-kernel7. Resolution & schemayesno
Session.Closekernel-to-controller4. Sessionnoyes
Session.Hellokernel-to-controller4. Sessionnono
Session.Openkernel-to-controller4. Sessionnono
Span.Opencontroller-to-kernel6. Invocationnono
Span.Settlecontroller-to-kernel6. Invocationnono
Status.Setcontroller-to-kernel5. Lifecyclenono
Value.Decodecontroller-to-kernel7. Resolution & schemayesno
Value.Expandcontroller-to-kernel7. Resolution & schemayesyes
Zone.Attributescontroller-to-kernel6. Invocationyesno
Zone.Closecontroller-to-kernel6. Invocationnono
Zone.Findcontroller-to-kernel6. Invocationyesno
Zone.Listcontroller-to-kernel6. Invocationyesno
Zone.Opencontroller-to-kernel6. Invocationnono
Zone.Requirecontroller-to-kernel6. Invocationyesno

Synchronous means the sender is blocked while the request is outstanding: it performs no other work and reaches no further state until the response arrives. Reentrant means the responder MAY issue further requests to the sender while the request is outstanding, and the sender MUST serve them. The two together are what make a module function reachable from inside an expression the kernel is evaluating on the controller's behalf (§6.7).

3. The framed carrier​

Everything in this section is byte-exact, and sdk/controller-protocol/vectors/framing.json carries the rows that assert it.

3.1 The frame​

A frame is:

length      u32, big-endian   bytes that follow this field
class u8 0x01 message frame, 0x02 data frame
metaLength u32, big-endian bytes of the meta region
meta metaLength bytes UTF-8
data the rest uninterpreted bytes
  • length MUST equal 1 + 4 + metaLength + len(data).
  • MAX_FRAME_LENGTH is 16777216 (16 MiB), the largest value length may carry. A reader MUST refuse a frame whose length field exceeds it, before reading the body — a reader that allocates first is a reader a peer can exhaust with four bytes.
  • A reader MUST refuse a frame whose metaLength exceeds length - 5, and one whose meta is not well-formed UTF-8.
  • A reader MUST refuse a frame whose length is below 5. The class byte and the four metaLength bytes are both mandatory, so no frame is shorter, and leaving 1–4 undefined would make the rule above underflow: a reader computing length - 5 in unsigned arithmetic wraps it to an enormous bound, passes its own metaLength guard and slices past the buffer, so four header bytes are a denial of service against an otherwise conforming reader.
  • length counts bytes and nothing else: there is no padding and no alignment.

Both fields are unsigned and big-endian. Big-endian because a frame header is read by eye in a hex dump as often as by code, and a width fixed at four bytes because a varint's own length is a second thing to get right at the one place a reader has not yet validated anything.

3.2 The envelope​

The meta region is the typed-frame JSON text (kernel/specs/durable-execution.md §6) of an object with exactly four members:

  • id — an unsigned integer correlating a request with its response.
  • session — the session the message belongs to, or null on Session.Hello, which precedes every session.
  • type — the message's name, exactly as messages/ spells it.
  • payload — the message body. On a request it is validated against that message's request schema. On a response it is the ok / error envelope below, and it is the ok member — never the envelope around it — that is validated against the message's response schema.

Each member's spelling is fixed, because two runtimes writing one envelope must write the same bytes: id is a plain JSON number, whole and non-negative; session is a string or null; type is a string; payload is the body, whose own values are typed frames and may therefore be tagged. The meta's members are in the typed frame's own canonical order — by key, as durable-execution.md §6.2 states — so nothing about how a runtime holds an envelope reaches the bytes.

session rides the envelope rather than being implied by the connection because one carrier instance serves every kernel instance in a process. A frame whose session names no open session is a protocol violation.

Ids are partitioned by parity: the kernel end mints even ids, the controller end odd ids, unique per carrier instance rather than per session. A reader therefore knows from a frame's id alone whether it holds a request the peer initiated or a response to something it sent itself, with no fifth envelope member to keep in agreement. An id MUST NOT be reused while a request bearing it is outstanding.

A response frame carries the requester's id and the same type, and its payload is an object with exactly one member:

  • ok — the response body; or
  • error — { "code": "<ERR_*>", "message": "<text>", "data": <value> }, data optional.

A message whose response in messages/ is null is a notification: no response frame is ever sent for it, and a peer that sends one is in violation.

3.3 The two frame classes​

A message frame (class 0x01) MUST carry an empty data region. Its whole content is the envelope.

A data frame (class 0x02) MUST carry type "Channel.Data", and its data region MUST be exactly payload.byteLength bytes long. Channel.Data is the only data frame. A reader MUST refuse a data frame whose type is anything else, and one whose data length disagrees with byteLength.

Channel.Open, Channel.Credit and Channel.Close are ordinary message frames. Flow control is therefore in the inventory and is vectorable like every other message, while the chunk itself stays raw: a stream chunk, a controller's standard-output write and a child manifest's output would otherwise inflate through base64 inside the typed frame, which is the cost that disqualifies a line-delimited JSON carrier in the first place.

A Channel.Data chunk MUST NOT exceed 8388608 bytes (8 MiB), which leaves a whole envelope's room under MAX_FRAME_LENGTH whatever the message names. A writer with more to send splits it across frames with consecutive seq.

3.4 Correlation and reentrancy​

Frames of different exchanges interleave freely on the one connection, correlated by id. A reader MUST NOT block on dispatch: it MUST read the next frame while a handler it started is still running, so a Callable.Call arriving while a Value.Expand is outstanding is served rather than deadlocking against it. That is not an optimisation — it is the only reason a synchronous, blocking request is representable at all, and §6.7 depends on it.

Ordering is guaranteed per channel (§8) and nowhere else. Two requests sent back to back may be answered in either order, and a runtime that needs ordering between two operations sequences them itself.

3.5 A refused frame is terminal​

A frame a reader refuses — a length over the maximum, a truncated prefix, meta that is not the typed frame of an envelope, an unknown type, a payload its message's schema rejects, a Channel.Data whose byteLength disagrees with the frame, an id or a session that names nothing — is terminal for that carrier instance.

A terminal carrier instance records its cause once: every call in flight on it, and every later call into it, MUST fail with that cause's code. The cause is decided by which end refused and what the kernel observed:

  • The kernel refuses a frame or message, on either carrier: the cause is ERR_CONTROLLER_PROTOCOL_VIOLATION. On the framed carrier the kernel MUST close the connection and end the host. On the ABI carrier the kernel MUST mark the controller unusable and MUST NOT make any further call into it.
  • A controller on the ABI carrier refuses a message — a request the kernel sent it, a notification, or the kernel's response to a request of its own — on whichever thread, and whether or not a kernel call is in progress. There is no process to exit and no connection to close, so the ABI carrier's host table carries a refuse slot in their place. The controller MUST call refuse with its own account of the refusal as UTF-8 text when it refuses, before it returns from the call that carried the refused message, if there is one. The kernel records ERR_CONTROLLER_PROTOCOL_VIOLATION as the cause, with that account as its message, and treats the instance as terminal as above. Where the refused message was a request carrying a response, the refusal MUST also return to the kernel as the error of that call, with ERR_CONTROLLER_PROTOCOL_VIOLATION. After calling refuse the controller MUST NOT make any further call into the kernel on that carrier instance, and the kernel MUST fail any such call with the recorded cause. refuse MUST be callable from any thread and MUST NOT block on dispatch. A call after the cause is recorded does not replace it. refuse is not a message: it is the ABI carrier's counterpart of closing the connection, and it carries nothing the message set names.
  • A controller host on the framed carrier refuses a frame: the host MUST close the connection. The kernel observes only the host disappearing, so the cause is ERR_CONTROLLER_HOST_EXITED; the host's own account of why reaches the kernel's stderr through its forwarded output (§3.6). Naming the real cause would need a refusal message, which is a generation change, and the kernel reports what it observed.

Whichever end refused, a framed host whose carrier instance is terminal is never restarted silently: a kernel that restarts it would hand back a controller with none of the state the failed calls assumed.

A carrier instance becomes terminal only through a carrier event — the kernel's own refusal, a framed host's connection closing, or an ABI controller's refuse — and never through a code a response carried. A controller propagates ERR_CONTROLLER_PROTOCOL_VIOLATION legitimately, as the error of a dispatch into another carrier instance that is terminal, and a kernel that read the code as a refusal would end every carrier instance that ever called into a broken one.

A runtime MUST NOT attempt to resynchronise a stream after a refused frame. The length prefix makes the next frame's boundary computable, which is precisely what makes "skip it and carry on" look reasonable — and it is not: a peer that sent one frame this protocol does not permit has already disagreed about the contract, and every later frame from it is a guess.

3.6 The carrier process's own output, and a controller's​

These are two different things, and conflating them is the obvious implementer error:

  • The host process's own stdout and stderr — whatever the process writes for its own reasons, including a runtime's own crash dumps — is forwarded unchanged by the kernel that started it, to the kernel's own stdout and stderr. It is not framed, not attributed to any resource, and carries no session.
  • A controller's stdin, stdout and stderr are three well-known channels over §8's primitive, opened per instance with the purpose values stdin, stdout and stderr. They belong to a session and a resource; they are flow-controlled; and they are what a module writing to standard output as data uses. Writing diagnostics there is forbidden — that is Log.Write (§9.1).

The protocol MUST NOT be carried over the process's own standard streams. A dedicated channel is what keeps the two apart: a controller's incidental println! would otherwise corrupt the frame stream.

4. Session​

4.1 Session.Hello​

The first message on a carrier instance, before any session exists. The kernel end states the generation it speaks and its own identity; the controller end answers with the generation it speaks and its own — its telo version, and the name and version of the runtime it runs on.

A generation mismatch MUST be refused with ERR_CONTROLLER_HOST_INCOMPATIBLE, naming both generations. So a handshake's generation admits every generation from 4, the first that defines this message, with no upper bound, and no generation change narrows it: a peer stating a generation this end does not speak has sent a valid handshake, and is refused as a mismatch, never as a refused message (§3.5). A runtime MUST NOT negotiate down to an older generation: a generation is a complete message set, and half of one is not a protocol.

4.2 Session.Open and Session.Close​

Session.Open mints a session for one kernel instance; every later envelope on that instance's behalf carries its id. Session.Close ends it — by then every instance created in it has been destroyed and every frame unwound, so the controller end is releasing bookkeeping rather than running work. It is reentrant all the same, because an inverse that runs late may still reach back.

A kernel MUST NOT reuse a session id within a carrier instance, and a controller end MUST refuse an Session.Open naming one already open.

5. Lifecycle​

This section carries the capability entry points that build, start and tear an instance down, and the revertible effects they return. What the lifecycle MEANS — that init() allocates and run() performs the observable I/O (a Telo.Provider, having no run(), acquires in init()), that frames open per lifecycle entry and unwind LIFO — is kernel/specs/revertible-effects.md's.

5.1 Binding and creating​

Controller.Bind obtains the controller a kind names and learns two things: the entry points it implements, and the controller-to-kernel operations it requires. The second is what §0.2's refusal is decided on. Selecting the candidate and fetching its bytes happen before this message and are not part of the protocol.

Controller.Create builds one instance. Its request carries the resource's own manifest document as a typed frame, the handle the kernel minted for the instance, the owner prefix a controller composes sub-resource ids under, the identity of the module that declared the resource and of the module that declares the controller, and the resolved invocation contract. For a native Telo.Callable, every reference at a slot whose every use is dependency is carried in the manifest as the referent's configuration — what the referent's own controller received, without kind and metadata, references inside it carried the same way — never as a handle. Its response narrows the bound entry points to what this instance actually carries, and — for a Telo.Sink — reports the sink's identity, its fan-out level and whether it can be drained synchronously, which the kernel needs before it may write to it.

Two members of that exchange are about how the controller was reached and when it is released. entryUrl is the location the kernel selected the controller's code from; selection happened before the protocol began (§0.1), so the controller end cannot re-derive it, and a host that must load the same code again is told rather than left to guess. teardownPriority answers what the teardown cascade cannot infer: resources unwind in ascending priority as a hard tier, and absence means 0. It is a tier rather than an ordering edge because it states an edge nothing captured — a log sink is reached through Log.Write and no reference slot, so no walk of the manifest finds what will log on the way down, and a sink therefore reports a HIGH value so that everything able to log has already unwound. Within a tier the order is the cascade's own, which kernel/specs/revertible-effects.md names as the counterpart to the per-resource frame order it defines.

A controller MUST NOT perform observable I/O in Controller.Create or Controller.Init, with one exception: a Telo.Provider, which has no Controller.Run, MAY acquire external resources in Controller.Init when each acquisition is an effect whose inverse releases it completely. The protocol cannot enforce either, and states them because a runtime's retry semantics rest on them: a failed init unwinds and discards the instance, so a retry sends a fresh Controller.Create.

5.2 Snapshot, run, provide, destroy​

  • Controller.Snapshot reads configured state — what the resource was declared with, as the kernel republishes it. Observed state is pushed instead (§5.5).
  • Controller.Run starts a Telo.Service or runs a Telo.Runnable. It carries an invocation context, so a boot run cancelled by a signal reaches the controller's own cancellation token.
  • Controller.Provide reads a Telo.Provider's value.
  • Controller.Destroy releases the instance, after every frame has unwound.

A Telo.Mount declares no entry point of its own: it is reached by whichever service mounts it, through Ref.Resolve (§7.1) and the target kind's own entry points. A Telo.Type has no runtime instance at all and names nothing here.

5.3 Sinks​

A Telo.Sink is written to directly, never through a dispatch: Controller.SinkWrite per record, Controller.SinkFlush to drain, Controller.SinkFlushSync to drain before returning, Controller.SinkClose at teardown. Per-record dispatch would be far too slow for a logging hot path and would emit telemetry from inside the telemetry path.

Controller.SinkWrite is a notification: a sink MUST NOT fail a write back to the runtime. A sink failure is counted out of band (Log.Drop, §9.1) and never propagated to whatever was being logged.

Controller.SinkFlushSync is synchronous: the kernel is blocked while it is outstanding, and it is a no-op at a sink that reported syncFlushable: false. It is one of exactly two synchronous kernel-to-controller messages, the other being Callable.Call (§6.7); a runtime that treats either as fire-and-return breaks the other end's contract — here the kernel returns from teardown before the records are durable, there §3.4's reader deadlocks. Synchronous flush is a sink capability, not a runtime guarantee: a kernel MUST NOT block on a sink that cannot be drained synchronously — on a single-threaded runtime that is a deadlock rather than durability.

5.4 Effects​

init() and run() RETURN what undoes them, and a runtime provides no teardown(). Across this boundary the chain is controller-owned — each step's body and each inverse is a closure only its author can run — so the kernel holds handles and drives them:

  1. Controller.Init / Controller.Run answer with a chain: an id the controller minted and one entry per step, in order, carrying only the step's reason. The chain is lazy; nothing has run.
  2. The kernel executes it step by step with Effect.Perform, threading each step's result into the next step's input. The kernel MUST NOT retain or interpret a result beyond that.
  3. As the body produces each inverse, the controller sends Effect.Register — the reason, and its own handle for the inverse — and receives the effect's kernel id. Registration is pushed as it happens, not returned at the end, which is exactly what makes the iterator body form work: a body that fails between allocations has already registered the completed ones, and the frame unwinds precisely what happened.
  4. At teardown the kernel sends Effect.Revert per registered effect, LIFO within a frame, innermost frame first. It never looks inside an inverse.
  5. Effect.Dispose is the controller asking for one effect's inverse to run now and leave its frame — idempotent, unordered and never cascading. A refusing inverse is raised to the caller here, unlike at teardown, because a disposal has one.
  6. Effect.Run is the imperative door: execute a chain in place against the resource's current frame, for an allocation whose lifetime is an operation rather than the resource. While it is outstanding the kernel is sending Effect.Perform back, which is why both are reentrant.

A chain id is minted by the controller end and is unique within its instance. An inverse handle is likewise the controller's.

5.5 Holds, status, events​

Hold.Acquire holds the kernel open; the application exits at zero holds. The kernel registers nothing for a hold: which frame owns it is a fact only the caller has, so the caller places Hold.Release in a chain itself. Registering at acquisition would put every hold on whichever frame happened to be open and would double-register wherever a caller also states it.

Status.Set publishes observed state: it replaces rather than merges, is sticky until teardown, is validated against the kind's status: block, and is illegal before the resource has started — except from a Telo.Provider, which reports from Controller.Init onward. A report made during Controller.Init is published when init returns and discarded with the attempt when it fails or defers, so a provider's reading is readable wherever a value resolves at startup.

Event.Emit puts a lifecycle or debug event on the kernel's event bus. A runtime MUST NOT emit one per effect; recovery failures surface through the resource's logger and the aggregate error instead.

6. Invocation​

6.1 The two directions​

Controller.Invoke is the kernel calling a Telo.Invocable's entry point. By the time it is sent, inputs have been validated against the resolved inputType; the result is validated against outputType on the way back. Neither validation is the controller's, and a controller MUST NOT assume it can skip its own checks because of them — a kind that declares no contract is validated against nothing.

Dispatch.Invoke is a controller asking the kernel to dispatch by name; Dispatch.InvokeResolved does the same at an instance the caller already resolved, so the invocation keeps the target's identity for tracing and error wrapping. Dispatch.Run runs a Telo.Runnable or starts a Telo.Service by name.

Every dispatch crosses back to the kernel. A controller holding a live instance MUST NOT call its entry point directly and escape the chokepoint: the invocation contract, execution zones, tracing, observed state and durable journaling all hang off it, and a dispatch that bypasses it is silently outside all five.

6.2 The invocation context​

Every dispatch carries a context: the cancellation source it is scoped to, the invocation and parent invocation ids, the trace id, the open zone stack and the durable step path. One member of the in-process context deliberately does not cross: the durable run handle, which is a live object with methods. The kernel is a pure conduit for it — it carries it and never calls it — so a second runtime threads a handle it owns rather than deserializing this one.

6.3 Cancellation​

Cancellation.Create mints a writable source for a trigger to own; Cancellation.Cancel cancels it, or arms it to trip at an absolute instant (a deadline is not a separate concept); Cancellation.Dispose releases a pending deadline and its subscribers without cancelling.

Cancellation.Signal is the kernel pushing a cancellation to a controller that observes the token. A token is polled between units of work, so a round trip per poll would put the carrier inside every loop; the controller end keeps a local mirror, seeded from the context it was handed and updated by this notification, and answers its own polls from it. A cancelled token raises ERR_INVOKE_CANCELLED, which is a signal: anything that catches rethrows it.

6.4 Zones​

Zone.Open opens the zone a resource's slot declares and returns the derived context plus the minted entry; Zone.Close ends that region with an outcome. A scope on the controller's side becomes a pair here because the carrier cannot see an async boundary the controller crossed — the controller is responsible for sending Zone.Close on every path out of the region, its own failure path included.

Zone.Require and Zone.Find are the same lookup with different failure modes — a refusal with ERR_ZONE_REQUIRED, and absence as a valid answer — and are two messages for that reason. Zone.List answers the undeclared case: every ambient zone correlated on an instance the caller holds. Zone.Attributes returns what each open zone DECLARES about its contents, from the closed vocabulary in sdk/zone-attributes/, each value being the author's own reason.

All four resolve against kind schemas and annotations the kernel holds, which is why they are kernel round trips rather than reads off the context the controller already has. All four are synchronous: a controller asks them to decide what to do next.

6.5 Spans​

Span.Open opens a trace span at an inbound boundary and returns the child context to thread into the handler; Span.Settle closes it with an outcome. parked is its own outcome and not a flavour of failure — a suspended invocation neither succeeded nor failed.

A kernel with tracing off MUST still answer Span.Open, returning the base context unchanged, so a controller has one code path whether or not a sink is attached.

6.6 Detached work​

Dispatch.Detach opens a detached task: the caller's cancellation and trace scope are replaced with the uncancellable root, so request teardown cannot abort the work and it does not nest under the request's trace. The task is tracked against the resource and drained, under a bound, when the resource tears down. Dispatch.DetachSettle reports that it ended. A failure goes to the event bus — there is no caller to raise it to — and either way the drain stops waiting.

A drain is not an inverse: it waits for in-flight work and then abandons it, where an inverse either succeeds or refuses, so it is not an effect and does not unwind on a frame.

6.7 Callables, and reentrancy​

Callable.Call is the kernel calling a Telo.Callable synchronously, with its arguments as a typed frame keyed by parameter name. It is synchronous and kernel-to-controller — one of exactly two, the other being Controller.SinkFlushSync (§5.3) — and it is the one of the two that makes §3.4 forbid a reader to block on dispatch, because the controller end must serve it while blocked on a request of its own.

The shape it exists for: a controller sends Value.Expand (§7.4) and blocks; the kernel evaluates the expression and reaches a module function whose controller lives at the very end that is blocked; the kernel sends Callable.Call on the same carrier instance, and the controller end MUST serve it while its own request is outstanding. A runtime that serialises its reads deadlocks here, and nothing in a manifest shows why.

A Telo.Callable's call is synchronous by contract: a controller MUST NOT make it await anything, and a runtime MUST NOT make Callable.Call a door back into asynchronous work.

7. Resolution & schema​

7.1 References​

Ref.Resolve turns a reference slot's value — a reference sentinel, an inline definition, or a normalized kind/name pair — into a live instance. expects names the contract the slot wants, so a mis-wire says what was missing rather than what was found.

The response reports whether the instance is local and lists the entry points reachable through it. Where the target's controller is bound in the same carrier instance and the same realm, a runtime MAY hand over the live object; otherwise the handle is a proxy exposing exactly those entry points and nothing else. A proxy MUST NOT expose a controller's own methods beyond its kind's capability entry points: a controller reaching into another's private surface is a coupling the realm boundary is there to prevent, and one that would work in process and fail across it.

Ref.Ensure is the inverse operation: a slot value in, a reference out, registering an inline definition into the declaring module's scope on the way. It is a separate message from Ref.Resolve — the two have different failure modes, and a controller that wants the target's identity for a dispatch wants this one.

Ref.Declaration reads the manifest a name was DECLARED with, resolved in the context that owns the asking resource. A declaration is readable whether or not the target has been constructed, which is what lets a slot wanting a fact about its target avoid an ordering edge — and what lets a resource read a slot pointing at itself.

7.2 Schemas​

Schema.Validate validates a value against an author-written schema through the runtime's own engine, so its formats and x-telo-* keywords apply and one process never holds two disagreeing validators. Schema.Compile compiles once and Schema.Check validates per invocation against the result — the shape a controller with a per-request schema wants. A compiled validator lives as long as the instance that compiled it.

Schema.Resolve resolves a named type reference to its schema, in the scope of the module that WROTE the name — never to whichever loaded module registered a type of the same name.

Schema.Check answers with a verdict and its issues; Schema.Validate raises ERR_INPUT_INVALID. A caller asking a question gets an answer; a caller demanding validity gets a refusal.

7.3 Plain-encoded values​

Value.Decode reads a value that arrived from OUTSIDE Telo — a transport body, a query or header map — as the schema it was promised says it holds: every slot declaring an instance value type with a plain encoding has its text decoded, and every declared scalar comes back in its CEL form. Text a slot's encoding does not read is refused with ERR_INPUT_INVALID, naming the slot and the form.

7.4 Compiled values​

A compiled CEL value is code, and code is owned by the side that compiled it. A controller never evaluates a Telo expression and never receives one to evaluate.

In every manifest document that crosses this boundary, a node holding a compiled value is written as the JSON object

{ "$teloCompiled": "/pointer/to/this/node" }

— an ordinary string-keyed map in the typed frame, needing no tag and no addition to that vocabulary's closed tag set. A controller reading its own manifest sees a placeholder wherever an expression stands, which is also what stops it treating an unevaluated expression as a literal.

Value.Expand evaluates the compiled value at a pointer into the instance's manifest, under the resource's own scope plus whatever extra bindings the controller supplies as typed frames — the handler scope a transport binds, the per-item bindings a composer binds. The pointer "" expands the whole document.

It is synchronous and reentrant: synchronous because a controller expands a value in order to act on it, and reentrant because the expression may reach a module function whose controller is at the blocked end (§6.7).

A Text the result fills is its text record. The texts a value expanded this way holds are settled exactly as the kernel settles a resource's texts at creation: which expressions bear text, and the language of the module that wrote each, are fixed when the instance is created. A Text or Message the evaluation produces — at the pointer, or beneath it — is judged in that language (ERR_MESSAGE_SYNTAX_INVALID, ERR_MESSAGE_SOURCE_LANGUAGE_MISSING, ERR_MESSAGE_PLURAL_CATEGORY_INVALID), a values pairing the evaluation completes is judged against its text (ERR_MESSAGE_PLACEHOLDER_UNBOUND, ERR_MESSAGE_PLACEHOLDER_VALUE_UNSUPPORTED), and each Text crosses as the record { text, context, lang, computed } with computed: true. A literal text the instance's manifest already holds as a record crosses as it is. This is a reading of the result within telo-6, not a new message.

An accessor-mode field is not a compiled value. A field its kind annotates x-telo-eval: accessor names a value for the controller's own consumer to resolve, and is never evaluated by either side. It therefore MUST NOT cross as a $teloCompiled node and MUST NOT be a target of Value.Expand. It crosses as an ordinary string-keyed binding map — { "root": "<binding>", "path": ["<member>", …] } for a chain, path empty for the bare binding, and { "value": <literal> } for a literal, including a literal that is itself shaped like a chain's map. A binding map carries no tag and adds nothing to the typed frame's vocabulary; a kernel MUST have refused, before the instance is created, any value at such a field that is neither a plain chain rooted at one of the field's declared bindings nor a literal holding no expression.

7.5 Needs​

A need's fulfilment crosses as a value, in every realm — never live and never as a proxy. A kind that consumes one whole declares a reference slot whose kind is a need type, and its controller opens what the slot holds by asking.

Need.Resolve names the slot by a JSON Pointer into the asking instance's manifest (/access). The kernel answers only when that pointer is a reference slot of the instance's own kind whose target is a need's fulfilment, so a controller cannot reach another module's fulfilment; any other pointer is ERR_REF_UNRESOLVED, naming the pointer. The answer carries the fulfilment's resource handle, its fields as a typed frame, the names of the fields that are sensitive as delivered — a field marked x-telo-sensitive, and a field whose value type declares a sensitive member (a Telo.Url's password) when its value holds one — and the constraints (version, features) and parameters of the outermost need of the whole-forward chain the fulfilment came through — the need whose constraints are enforced. A slot referencing a fulfilment directly, with no need in the chain, carries none. For a need of a kernel-declared address type (Telo.PublicUrl, Telo.PublicHostPattern) the answer also carries port, the number the need's port parameter names as the application resolved it, which is what lets a server refuse an address on a port it does not listen on. The answer carries no environment name and no source.

Need.Verify reports what the consumer observed on the real thing behind the fulfilment — the actual version as semver text, the features present — one member per constraint its slot observes. The consumer observes, the kernel decides: the kernel compares the observation with the outermost need, and a controller MUST NOT compare a range itself. Success answers nothing. A mismatch is ERR_NEED_UNMET, whose structured data holds need (the need path), type, source (env, declaration or host), required and actual. An observation at a slot that does not observe, a member for a constraint the slot does not observe, an observed constraint left out, or a version that is not semver is ERR_NEED_OBSERVATION_INVALID, naming the kind and the slot. Observed features the need type does not list are ignored.

The observation is due by the end of the consumer's startup, whether or not the need states a constraint: when Controller.Init answers for a Telo.Provider, when Controller.Run answers for a Telo.Service, when the first Controller.Run answers for a Telo.Runnable and the first Controller.Invoke for a Telo.Invocable. A slot on a kind of any other capability cannot declare observes. An observing slot not reported by then is ERR_NEED_OBSERVATION_INVALID.

The kernel stays the authority that redacts: every sensitive fulfilment value joins the redaction set of every module context it reaches. The sensitivity marks let an SDK hold a marked field in a value that prints [redacted] where no kernel sees the output, and a runtime recording protocol traffic censors every field Need.Resolve marks.

8. Channels​

A channel is a one-way, flow-controlled byte stream between the two ends, identified by a channelId unique within its session. Its four messages are Channel.Open, Channel.Data, Channel.Credit and Channel.Close, and all four are notifications — a channel is a stream, not a request/response exchange, and a per-chunk acknowledgement would halve its throughput to no purpose.

Ordering is guaranteed within a channel. Channel.Data frames of one channel MUST be delivered in seq order, seq starting at 0 and increasing by one per frame, and they MUST sit between that channel's Channel.Open and Channel.Close.

Flow control is credit in bytes. Channel.Open grants the initial credit; Channel.Credit grants more. A sender MUST NOT send a Channel.Data whose byteLength exceeds its outstanding credit, and each frame consumes credit equal to its byteLength — framing bytes are not counted, so a receiver's budget is a budget in payload rather than in a number it has to derive. A receiver MUST grant credit as it consumes, and MUST NOT grant unbounded credit: a channel nobody bounds is a buffer nobody bounds.

Channel.Close ends the channel. An error member closes it abnormally: the reader sees a failure rather than end-of-input, which is what tells a truncated stream from a finished one. A channel whose session closes is closed with it.

purpose says what a channel carries:

purposecarries
stdin / stdout / stderra controller's own standard streams (§3.6)
streamone Telo.Stream value crossing as part of a payload
runtime.stdin / runtime.stdout / runtime.stderra child manifest's streams (§9.2)

A Telo.Stream value is live: it is consumed by reading, so it exists exactly once and MUST NOT be written into a typed frame. It crosses as a channel, named by its channelId at the slot where the value stands.

9. Host facilities​

9.1 Logging​

Log.Write emits one record, as a notification. The kernel stamps the resource's identity, its module, its import-alias scope and the active span's trace and span ids; a controller never passes those. A controller emits diagnostics only through this — writing to standard error for diagnostic purposes is forbidden, while writing to standard output as data is a separate and legitimate concern (§3.6).

Log.Threshold is the kernel telling a controller the effective severity threshold for its logging scope, at creation and whenever it moves. A controller answers its own enabled from this. A round trip per call would make the cheapest question in the system the most expensive one, and a controller that cannot ask cheaply builds records it then discards.

Log.LevelFor resolves a sink's declared level to a severity number, falling back to the effective scope threshold when the sink declares none. Log.Drop counts dropped records against a sink, taking a count so a sink that loses a whole batch is not undercounted to one per failure.

A sink never attaches itself. The kernel attaches exactly the instances the root application's logging.sinks / tracing.sinks list, and this protocol carries no message for attaching one.

9.2 Child manifests​

Runtime.Run starts a child manifest. The kernel starts it as its own session — served by the same carrier instance — and answers with the child's run id, that session's id, and the three channel ids carrying its standard streams. Values for the child's declared inputs are supplied by the name the child declares, never by the environment variable it binds them to: the child's env: mapping is its own business, and supplying a declared input by setting the variable behind it lets a caller reach keys the child never declared.

env is a different thing and both may be sent: it is the host environment the child runs in, which the child's own variables: / secrets: bind from. A runtime forwards it to the child unchanged and interprets nothing in it; absent, the child runs in the calling resource's own environment. It is never a second way to supply a declared input: an entry whose key a child binds loses to the value supplied by name, exactly as a host environment variable loses to one supplied by name.

Runtime.Started is sent once every one of the child's boot targets has been dispatched — so a server it declares is listening — and is never sent for a child that exited first. Runtime.Exited is sent when it has finished, including after a cancel, by which time both output channels have closed. Runtime.Cancel stops it and tears it down; it is idempotent and safe after the child has finished.

A child that is refused — its load fails, or it fails before it finishes starting — writes the refusal to its stderr channel and exits non-zero. The text is the refusal's own message, then one line per diagnostic the refusal carries: two spaces, then [<CODE>] <message> (<message> alone for a diagnostic with no code). A diagnostic's further message lines and its details lines follow it, two spaces further in; a nested context's diagnostics (an import's own failed resources) follow their entry, two spaces further in; an entry that failed only because another did (derived) writes no line of its own, while its nested diagnostics still do. No source location is written — the located, structured form is Runtime.Check's answer. A code is written as the refusal carries it: a diagnostic of the load's static analysis keeps the analyzer's unprefixed code, so a caller matching [<CODE>] <message> can tell a refusal by analysis from the same mistake refused by a guard (ERR_<CODE>).

Runtime.Check runs the static-analysis pass over a manifest and its import graph. It is answered by the analyzer of the kernel's own telo generation, and a runtime MAY run that analyzer inside the controller host — an analyzer is not a kernel, and no kernel ever runs inside a host. Its diagnostics are the ones telo check reports for the same manifest, with the same codes, messages and locations. A failed import is one of them — its coded diagnostic at the imports: entry (path imports.<alias>) — and never loadError, which is set only when the graph could not be loaded at all (an unreadable entry, a refused partial). Either is an answer, not an error.

Both are reentrant: a child's output is flowing on its channels while the request that started it is still settling, and Runtime.Check may reach back for module files.

9.3 Module files and the environment​

Module.File resolves a module-relative reference against the module that DECLARED the resource — a path its author wrote. Module.ControllerFile resolves one against the module that declares the CONTROLLER — the file ships with the code asking for it. Native.File resolves a platform-specific file by the logical name a native: entry declares. All three answer with a URI rather than a filesystem path, because a path is only what one kind of host happens to hold, and all three are asynchronous because a published module's layers are fetched on first use.

Env.Read reads one environment entry in the resource's module scope, one name at a time. A map handed over at creation would ship everything a controller never asks for across a process boundary, and a key the application binds to a declared variable or secret MUST read as absent here — that is the same guardrail a kernel applies in process.

10. Error codes​

A failed request answers with an error payload (§3.2) carrying a code, a message and optional structured data. The table below is the set of codes this protocol defines or re-raises; a controller's own declared throws: codes cross unchanged and are not listed here.

coderaised whencarried by
ERR_CONTROLLER_HOST_INCOMPATIBLEthe two ends report different protocol generations, or a controller requires an operation this generation does not name, or an artifact declares a generation the kernel does not implementSession.Hello, Session.Open, Controller.Create
ERR_CONTROLLER_HOST_EXITEDthe controller host exited or closed its connection without the kernel having refused a frameany request in flight, and any later call into the terminal carrier instance
ERR_CONTROLLER_PROTOCOL_VIOLATIONa peer sent a frame or message this protocol does not permit and the kernel refused it, or a controller on the ABI carrier refused oneany request in flight, and any later call into the terminal carrier instance
ERR_INPUT_INVALIDa value did not satisfy the schema it was promised againstDispatch.Invoke, Dispatch.InvokeResolved, Schema.Validate, Schema.Check, Value.Decode
ERR_OUTPUT_INVALIDa result did not satisfy the resolved outputTypeDispatch.Invoke, Dispatch.InvokeResolved, Controller.Provide
ERR_CONTRACT_UNRESOLVABLEa declared contract resolved to no schema at allController.Create, Dispatch.Invoke, Dispatch.InvokeResolved, Schema.Resolve
ERR_SCHEMA_PROJECTION_UNRESOLVEDa projected schema could not be resolvedSchema.Resolve
ERR_FUNCTION_FAILEDa module function raisedCallable.Call, Value.Expand
ERR_INVOKE_CANCELLEDthe invocation's token was cancelled — a signal, rethrown by anything that catchesController.Invoke, Controller.Run, Dispatch.Invoke, Dispatch.InvokeResolved, Dispatch.Run
ERR_DURABLE_SUSPENDEDa durable run parked — a signal, rethrown by anything that catchesController.Invoke, Dispatch.Invoke, Dispatch.InvokeResolved
ERR_ZONE_REQUIREDa required zone was not openZone.Require
ERR_EFFECT_SCOPE_CLOSEDan effect was registered against a scope that has fully unwoundController.Init, Effect.Perform, Effect.Register, Effect.Run
ERR_OBSERVED_STATE_BEFORE_STARTobserved state was reported before the resource started, by a resource that is not a Telo.ProviderStatus.Set
ERR_OBSERVED_STATE_UNDECLAREDobserved state named a field the kind's status: does not declareStatus.Set
ERR_OBSERVED_STATE_INVALIDobserved state did not satisfy the kind's status:Status.Set
ERR_RESOURCE_NOT_INVOKABLEa dispatch named a resource with no invocable entry pointDispatch.Invoke, Dispatch.InvokeResolved
ERR_SPAN_ATTRIBUTE_INVALIDa span attribute name was malformedSpan.Open
ERR_INTERPOLATION_HOLE_NOT_CONVERTIBLEan interpolation hole held a value CEL cannot convert to textValue.Expand
ERR_HOST_PATH_RELATIVEa computed value at a host-path slot was not absoluteValue.Expand
ERR_MESSAGE_SYNTAX_INVALIDa computed text is not a well-formed Message or TextValue.Expand
ERR_MESSAGE_SOURCE_LANGUAGE_MISSINGa computed text selects a plural case and the module that wrote it declares no lang:Value.Expand
ERR_MESSAGE_PLURAL_CATEGORY_INVALIDa computed text selects a plural category the language it is written in lacksValue.Expand
ERR_MESSAGE_PLACEHOLDER_UNBOUNDa text's values lack a name the text readsValue.Expand
ERR_MESSAGE_PLACEHOLDER_VALUE_UNSUPPORTEDa text's value is neither a string nor a number, or a selector's is not a numberValue.Expand
ERR_MODULE_FILES_UNAVAILABLEa module file could not be staged or is unpinnedModule.File, Module.ControllerFile
ERR_REF_UNRESOLVEDa pointer names no reference slot of the asking instance's kind, or one whose target is not a need's fulfilmentNeed.Resolve, Need.Verify
ERR_NEED_UNMETan observation does not satisfy the outermost need; data holds need, type, source, required and actualNeed.Verify, and the entry point whose controller reported it: Controller.Init, Controller.Run, Controller.Invoke
ERR_NEED_OBSERVATION_INVALIDan observation at a slot that does not observe, one missing or extra member, or a version that is not semver; or an observing slot left unreported when the entry point its deadline names returnsNeed.Verify, and that entry point: Controller.Init, Controller.Run, Controller.Invoke
ERR_NATIVE_FILE_UNAVAILABLEno native: entry of that name matches the host, or its staged file could not be brought to its pinNative.File

ERR_DURABLE_SUSPENDED and ERR_INVOKE_CANCELLED are signals, not errors. Both ends MUST re-raise them out of any construct that catches — a try step, a catches: block, a retry policy — and a runtime MUST NOT count either toward a kind's declared throws:.

ERR_CONTROLLER_HOST_EXITED and ERR_CONTROLLER_PROTOCOL_VIOLATION are carrier codes, in no message's errors list: a terminal carrier instance records one of them as its cause, once, and every call into it fails with that code (§3.5).

11. Conformance​

Conformance vectors live at sdk/controller-protocol/vectors/ — framing.json, messages.json, sequences.json and carrier-equivalence.json — and they are normative. Each opens with a $comment declaring its tables so, as kernel/specs/durable-execution.md §6.6 does for the typed frame. A runtime's tests MUST execute all four, with no skip path: a row class a runner cannot execute is a failing test, never a silent zero.

A conforming runtime:

  1. carries every message in §2, in the direction it declares, on every carrier it implements — and refuses a controller needing an operation the running generation does not name, at Controller.Create, with ERR_CONTROLLER_HOST_INCOMPATIBLE (§0.2);
  2. reports one generation in the handshake for both carriers and refuses a mismatch rather than negotiating down (§0.3, §4.1);
  3. compares a need's constraints kernel-side only, answering Need.Resolve for a need-type slot of the asking instance's own kind and nothing else, and holding every observing slot to its deadline (§7.5);
  4. encodes every value as the typed frame of kernel/specs/durable-execution.md §6, and invents no second encoding (§1.3);
  5. frames exactly as §3.1 says, refuses a length over MAX_FRAME_LENGTH before reading the body, and refuses a Channel.Data whose byteLength disagrees with the frame;
  6. carries session, id, type and the payload on the envelope, partitions ids by parity, and sends no response for a notification (§3.2);
  7. reads without blocking on dispatch, so a reentrant request is served while a synchronous one is outstanding (§3.4, §6.7);
  8. treats a refused frame or message as terminal for the carrier instance, recorded once as ERR_CONTROLLER_PROTOCOL_VIOLATION when the kernel refused it or a controller on the ABI carrier refused it through refuse — a request, a notification or the kernel's response to its own request, inside or outside a kernel call — and as ERR_CONTROLLER_HOST_EXITED when a framed host closed its connection; ends a carrier instance only on one of those carrier events, never on a code a response carried; and never resynchronises and never restarts a host silently (§3.5);
  9. keeps the carrier process's own output separate from a controller's standard streams, and never carries the protocol over the process's own streams (§3.6);
  10. drives an effect chain as §5.4 states — lazy, stepwise, inverses registered as they are produced, reverted LIFO by handle, never interpreted;
  11. dispatches every invocation through the kernel's chokepoint, in both directions (§6.1), and carries no durable run handle across (§6.2);
  12. pushes cancellation rather than requiring a poll to cross (§6.3), and re-raises both signals out of everything that catches (§10);
  13. owns compiled values kernel-side, writes a compiled node as {"$teloCompiled": …}, and evaluates no Telo expression on the controller end (§7.4);
  14. bounds every channel by credit in bytes, delivers Channel.Data in seq order, and distinguishes an abnormal close from end-of-input (§8);
  15. starts a child manifest as its own session and runs no kernel inside a controller host (§9.2);
  16. executes all four vector files, in every language that implements a carrier.