Skip to main content
Status: Draft Type: Standards Track Category: Core Created: 2026-08-20 Requires: CIP-2 (verifiable off-chain compute), CIP-6 (in-PVM actor SDK), CIP-11 (runner connectivity and canonical job encoding) Related: CIP-23 (TEE execution and attestation)
Cowboy Cloud and Cowboy Chain. This CIP specifies the Cowboy protocol. Cowboy Cloud runs on this protocol today. Cowboy Chain, a public network on the same protocol, is under consideration for a future release. Where the two differ, this CIP says so.

Background

At the pre-CIP-45 baseline, Cowboy actors await an LLM runner through runner.llm(), which lowers to JobType::Llm and resumes with a generic RunnerResult. An actor that needs a structured answer passes a schema string as response_model and parses the model’s text itself. The runner only checks that the text parses as JSON, the node does not validate the result, and the actor cannot tell which model answered: the request carries a model hash, but the executor serves a process-wide default. CIP-45 adds schema-validated structured output to the existing Llm job, behind the same runner.llm() entry point, and replaces the free-form response-model string. An actor passes a CowboyModel class as response_model, or a schema’s text as response_schema. The runner returns a value that conforms to the schema or one closed failure code, the node validates the value against the stored schema before accepting it, and the actor reads it from the existing RunnerResult. A request names an exact model or lets the runner choose one, and every answer names the model that served it. Plain runner.llm() calls and plain Llm jobs keep their current behavior. When CIP-45 was drafted, the network behaved as follows (historical and informative):
  • JobType::Llm carries a model hash, prompt, system prompt, temperature, token limit, and a final optional response-model string; JOB_SPEC_VERSION is 3.
  • An omitted model is encoded as blake3_id(""), which the dispatcher treats as unconstrained; "*" matches any named model.
  • The actor path pins LLM jobs to EconomicBond with one runner, synthesizes a result schema with data_format="json", and reads runner.llm keyword arguments by name, silently dropping unknown ones and some wrongly typed ones.
  • The RPC returns job_type as JSON; the python-sdk builds jobs by passing JSON to the codec’s normalize, and the binding parses it with serde_json without rejecting unknown fields.
  • The executor ignores model_id, serves LLM_MODEL, and checks only that returned text parses as JSON.
  • On a provider error the executor submits {"error":"llm_api_error",...} as an ordinary result.
  • The provider client has no structured-output request mode.
  • The node deserializes a generic RunnerResult and does not enforce max_return_bytes.
  • EconomicBond settlement accepts the single result without inspecting it.
  • Under the compile-time transform, await runner.llm(...) lowers to a send step that CBOR-encodes the keyword arguments as given and a resume step that wraps the reply in RunnerResult.
  • The registry requires listed models for runners advertising llm.

Goal

CIP-45 provides:
  1. schema-validated structured output without actor-side text parsing;
  2. node validation of every structured result against the request’s schema;
  3. explicit model selection, either an exact model or a runner-chosen one, with the serving model reported;
  4. a closed set of failure codes an actor can act on;
  5. no additional continuation state, and schemas derived when the actor is built rather than on every call; and
  6. unchanged behavior for plain Llm jobs and plain runner.llm() calls.
CIP-45 defines one structured model response per job. Actor-defined tools, conversation history across turns, and multi-runner agreement over model output are future work.

Specification

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

Activation

CIP-45 is active from genesis. A network that adopts it launches from a fresh genesis with validators and runners that implement it, and validators admit structured Llm jobs from height zero. Structured calls depend on no PVM activation height: an actor’s schemas are part of its deployed source, and the compile-time transform and the SDK’s Python compiler lower a structured await alike. This CIP defines no activation height; a running network adopts it only through a fresh genesis (Compatibility).

Developer interface

The exact helper syntax is non-normative. The SDK MUST provide equivalent semantics to this example:
res.failure covers a runner that ran and failed. A job the chain gives up on ends this method instead (Terminal failures): the example is a decorated continuation, so its continuation is deleted and nothing after the await runs. An author who handles that case writes an undecorated async method with try/except JobFailed around the await. A call selects structured output when it passes response_model, a CowboyModel class, or response_schema, the canonical text of a JsonSchemaV1. response_model serves an actor built with cowboy build, which records each model class’s schema in the artifact (Schema derivation). response_schema serves an actor deployed without the builder: its author supplies the text (Schema derivation), and the node checks it on submission. The SDK MUST reject a call that passes both, and a response_model that is not a CowboyModel class, such as a schema string. A call that passes neither is a plain call and lowers to a plain Llm job as before. On the structured path, runner.llm accepts the prompt as its only positional argument, the LLM arguments model, system_prompt, temperature, max_tokens, timeout_blocks, and response_model or response_schema, and the job-level arguments that the actor job envelope accepts for every job type (secrets, verify, volumes, and hire) with their existing meaning. Before submission, the SDK MUST reject any other keyword argument, including verification, a wrongly typed argument or one outside the node’s admission bounds, a model class without a derived schema, and an empty or * model name. It checks that response_schema is non-empty text of at most 8 KiB and leaves the JsonSchemaV1 check to the node. The structured path MUST NOT fall back to a plain job. model="<name>" names an exact model; omitting model lets the runner choose. A CowboyModel subclass has the fields, constructor, field() handling, __repr__, and __eq__ of the equivalent dataclass. Defining and constructing one MUST NOT require exec, which the consensus execution environment does not provide, so the SDK does not call dataclasses.dataclass for it. A quoted annotation is classified by its text: ClassVar, InitVar, or KW_ONLY, bare or qualified by its module, marks a pseudo-field, and anything else is an ordinary field, so a model has the same fields on chain and off chain. Because parse(T) passes every field to the constructor, the SDK also rejects before submission a model class, or a model nested in it, that defines its own __init__ or leaves a field out of __init__. The model class is named once. When response_model= is a name bound at module scope that the continuation does not rebind, or an attribute or subscript of such a name whose subscripts are literals or such names (MODELS["tag"]), the reply is a StructuredResult: a RunnerResult that knows its class, whose value is the parsed instance. The resume leg evaluates the expression again, as it resolves a name callee, so nothing is added to continuation state, and a reply carried across a later await keeps its class. Any other response_model expression, one that no longer evaluates to a model class on resume, and every response_schema call give a plain RunnerResult, whose value is the value as plain data and which parse(T) reads. RunnerResult gains these accessors: The SDK also exposes the failure codes as named constants, the schema a class sends as M.response_schema(), and type information under which value is an M or None and failure is one of the eight codes. The accessors check the complete shape of a structured result object as Result defines it, and raise an error on any other body. A chain-authored failure never reaches them: the await handles it first (Terminal failures). parse(T) uses the field information cowboy build attaches to T and does not evaluate annotations at run time. It relies on the node’s validation against the stored schema and checks only that the value fits T while constructing it. A float field accepts an integer, since the canonical result form writes an integral number as one; a union field is built from the first member, in annotation order, that the value fits; and a Literal field takes a value equal to one of its literals and of the same type, so True never matches 1. Existing RunnerResult attributes keep their behavior. The node validates results only for structured jobs, and the accessors identify a structured result by its shape. Actor code therefore reads the accessors only on the result of a structured call. Structured calls use the existing continuation lowering and callback delivery. The send step replaces the response_model class with the schema text the builder attached to it, carried as the response_schema keyword argument, a CBOR text string holding the canonical schema JSON. A runner awaitable makes the same replacement when runner.llm(...) creates it, so every send path, a JobGroup included, sends the same request. The resume step binds the reply as described above. Nothing is added to continuation state.

Schema derivation

A response schema is a pure function of the actor’s source. cowboy build derives the JsonSchemaV1 of every model class in the single-module artifact it assembles from the actor’s project, and appends to the artifact, after the actor’s code, a section that opens with the line # cowboy-models. For each model class whose name the module binds once, the section sets either __cowboy_schema__, the canonical schema text, and __cowboy_fields__, the field information parse(T) uses, or __cowboy_schema_error__, the reason the class has none, in at most 240 characters. The schema is therefore part of the deployed source: the node executes it and never derives one, and a change to these rules changes only actors built after it. The builder’s compiler contract is cowboy-actor-compiler/v9. Derivation reads source syntax, so from __future__ import annotations does not change the result. The shared vectors in protocol/fixtures/cip45/derivation.json pin the derived bytes, and the builder MUST reproduce them. The builder refuses, under rule response-model-schema and with a hint for the fix, a response_model=<name> that names a model class with no schema, a model class whose name the module binds more than once, or a pydantic model (a class whose base is BaseModel). An actor deployed without the builder, its source deployed as written (for example with cowboy deploy --code), has no model section, so it passes the schema itself as response_schema. The text MUST be a JsonSchemaV1 in canonical form (Canonical JSON and schemas): the node re-canonicalizes it and rejects the call unless the bytes are unchanged; the SDK does not check the form. Off chain, CPython’s json.dumps(schema, sort_keys=True, separators=(",", ":"), ensure_ascii=False) produces it for a schema whose numbers are all integers, and the actor embeds the result as a string literal. The PVM’s json module escapes some control characters differently, so the actor does not compute the text on chain. In a project built with cowboy build, M.response_schema() returns the exact text the builder derives for a model class M. A model class is a class statement at the top level of the assembled module whose only base is the name CowboyModel, imported from cowboy_sdk under that name and bound nowhere else, with no class keywords or decorators. A class from another file of the actor’s project qualifies once the builder combines the files; model inheritance and classes from outside the artifact have no schema. Its fields are the names annotated directly in the class body (name: annotation, with or without a value), in source order. A class with no fields has no schema, and neither does a class whose body defines __init__, contains a nested class, an annotation inside a compound statement such as if or try, a ClassVar, InitVar, or KW_ONLY annotation, or a field whose value is a call with ** arguments or an init argument other than the literal True. parse(T) calls the constructor with every field: a class’s own __init__ need not accept them, and such a call, field(init=False) for example, can leave a field out. Annotations are resolved syntactically. str, int, float, bool, and list are the builtin names while neither the module nor the class body binds them. Optional, Union, List, Literal, and Annotated are the typing names: imported from typing under their own names, or written typing.<name> after a plain import typing. Aliases, other attribute forms, string annotations, and names the module rebinds produce no schema node. A union is flattened as typing flattens it: a member that is itself a union or an Optional contributes its members, and None makes the node nullable. One member plus None gives that member’s node with type [<T type>, "null"], and null appended to its enum when it has one. Two or more members give {"anyOf":[...]} with the members’ nodes as branches in source order, followed by {"type":"null"} when the union includes None; a union has at most 8 branches, the null branch included. Annotated on a union gives the anyOf node a description, which canonical key order writes after anyOf. A union of only None, and a union that joins a union described by Annotated into another union, have no schema node. Neither does an Annotated whose type already carries a description from another Annotated, such as a directly nested Annotated. The object node for a class has type "object", one property per field, required listing every field name in source order, and additionalProperties false. Its description is the class docstring, meaning the first statement of the class body when it is a string literal, normalized as Python’s inspect.cleandoc does; an empty result gives no description, and no other docstring source is used. Field defaults do not change the schema: every field is required, and a field that may be absent is declared Optional. Any other annotation, including bytes, dict, tuple, set, a bare list, Any, and Literal[None], has no schema node, and neither does a model class that reaches itself through its fields, a field name outside the property name pattern, or a field name Python mangles (a leading __ without a trailing one). A class that uses any of them has no schema. A class whose schema would exceed 8 KiB of canonical JSON or depth 10 also has no schema; derivation MUST stop as soon as its partial output crosses either bound, so its cost stays bounded however model classes reference one another. __cowboy_fields__ is a zero-argument function, so a field that names a model class resolves when parse(T) runs. It returns one (name, kind) pair per field in source order, where kind is ('str',), ('int',), ('float',), ('bool',), ('literal', (v1, ..., vn)), ('list', kind), ('optional', kind), ('union', (kind1, ..., kindn)) with the members in source order, or ('model', <class>, ((name, kind), ...)). The existing CowboyModel.schema() keeps its current output; it is not used for structured output.

Request encoding

Structured jobs extend JobType::Llm (tag 0x00). CIP-45 adds no job type, job_kind byte, or MRU key. Llm carries model_id, prompt, system_prompt, temperature, max_tokens, and a final optional response_schema: CanonicalJsonBytes, which begins with a presence byte: Decoders MUST refuse any other presence byte, a schema longer than 8 KiB, and schema bytes that are not already canonical. At the same surrounding codec version, a plain job without a response schema keeps its bytes, job_spec_hash, and req_digest. At the current codec baseline, JOB_SPEC_VERSION is 5. CIP-45 requires no additional version increment on that baseline because a network adopts it only at a fresh genesis. Both job_spec_hash and req_digest include the version byte; their preservation above does not promise compatibility with the historical version-3 codec. In the codec’s Rust and serde form the field is response_schema: Option<CanonicalJsonBytes>, on JobType::Llm and on the Llm job intent that normalize maps into it. In JSON, response_schema is a string holding the canonical schema text; it is omitted when absent and defaults to absent when missing, so the JSON of a plain job carries no response field. Codecs that predate CIP-45 ignore unknown JSON fields, so one would silently drop response_schema and build a plain job. A client that builds a structured job through JSON MUST confirm that the canonical bytes the codec returns carry the schema before submitting them. A structured Llm job MUST satisfy the existing Llm rules and:
  • response_schema satisfies JsonSchemaV1;
  • prompt is non-empty;
  • max_tokens is in 1..=JobSpec.bounds.max_output_tokens;
  • model_id is not blake3_id("*");
  • result_schema is present with data_format="llm_structured.v1" and 256 <= max_return_bytes <= 65,536; and
  • the verification profile is the one in Verification and settlement.
For a structured job without a result_schema, normalize MUST supply max_return_bytes=65,536, expected_execution_ms=1,000, and data_format="llm_structured.v1", the actor job envelope’s existing defaults with the structured format, so actor and account submissions receive the same result schema. The actor job envelope maps the response_schema keyword argument into the structured field, and MUST reject an llm call that carries a response_model keyword argument in any form. When response_schema is present, the envelope MUST reject the call if response_schema is not a CBOR text string holding a canonical JsonSchemaV1, if the prompt is not a text string, if model is present and breaks the model-name rules, or if system_prompt, temperature, or max_tokens is present with the wrong type. It never drops a malformed argument into a plain job. An llm call through the actor job envelope MAY carry timeout_blocks, an integer in 10..=600 that replaces the envelope’s default of 30 blocks, and max_tokens up to 32,768. The envelope sets bounds.max_output_tokens to at least max_tokens. A structured call’s timeout_blocks above 300 also raises bounds.max_wall_time_seconds to that many seconds: blocks are at least a second apart, and the runner bounds a structured job’s provider request by it. The envelope rejects a structured call with either argument outside its range. A plain call keeps its existing behavior: an out-of-range timeout_blocks takes the default, and the runner refuses an out-of-range max_tokens. Submission and callback delivery follow the existing job rules. An actor receives the result through its callback; a job submitted without a callback handler leaves the result on chain for the submitter to read.

Model selection

A structured job reuses Llm.model_id:
  • model_id == blake3_id("") requests Auto: the runner chooses one model it serves that supports structured output. The choice is operator policy and need not be deterministic.
  • Any other model_id names a model: the runner MUST serve the listed model whose blake3_id equals model_id, and MUST NOT substitute another model or a process-wide default.
A named model given to the SDK MUST follow the CIP-2 supported_models entry rules: 1 to 128 bytes, no leading or trailing Unicode whitespace, and never *. Names are compared byte for byte, without normalization. A runner states whether it serves structured jobs in its registration, as the RunnerCapabilities flag structured_output, which the registry accepts only from a runner advertising llm. A runner may state it only when its executor serves structured jobs for every model it lists: it has a client for a serving stack with structured output, and every supported_models entry other than * serves structured output (with only * listed, the model an Auto job resolves to does). A named structured job reaches any runner with the flag that lists the model, so a runner that disables structured output for one listed model does not state the flag. A runner whose only provider client has no structured-output mode, such as an Anthropic-only runner, does not state it. The operator sets the flag in the registration (cowboy runner register or update-capabilities with --structured-output); startup never changes a registration. At startup a runner refuses to run when its row states the flag without a structured executor behind it, or with a supported_models list other than the one its executor resolves structured jobs against (RUNNER_SUPPORTED_MODELS), since named jobs and Auto results are matched against the row. A runner that could serve structured jobs but whose row omits the flag only reports that. For a structured job, Auto or named, the CIP-2 capability filter admits only runners that state structured_output. For a named model it further requires that one of the runner’s supported_models entries other than * has blake3_id(entry) == model_id. Plain Llm jobs keep the existing filter, in which * matches any model and structured_output is ignored. This filter decides the candidate set, so it is consensus-critical. It binds the node’s selection and the runner’s acceptance of an assignment. Every result reports the serving model as model. It is non-null for a structured value and MAY be null for a failure when no model was engaged. For a named model, a non-null model MUST satisfy blake3_id(model) == model_id. For Auto, it MUST follow the model-name rules above and MUST be one of the supported_models in the runner’s registration when the result is submitted; a runner that registers * may report any valid name. The reported model is the selected runner’s claim and carries the same trust as the value. CIP-23 attestation MAY bind it. A model that cannot serve the schema yields the failure code unsupported_feature. It never permits fallback to another model or unvalidated output. Plain Llm jobs keep their current model behavior.

Canonical JSON and schemas

CanonicalJsonBytes is the existing canonical JSON codec the JobSpec boundary already uses for JSON-shaped fields. It accepts UTF-8 only, rejects duplicate keys after unescaping, sorts object keys by their unescaped UTF-8 bytes, accepts integers only, emits one escape spelling and no insignificant whitespace, and rejects any wire body that is not already in canonical form. Schemas further narrow that domain: every integer in a schema MUST lie in -(2^53 - 1)..=(2^53 - 1). A structured value inside RunnerResult.data is a JSON value rather than CanonicalJsonBytes, and its result identity comes from CIP-23 canonical_result_cbor, which writes an integral number within the CBOR integer range as an integer and any other finite number as a binary64. An integer node accepts only a number written as an integer in the safe-integer range: one written with a fraction, an exponent, or as -0 is invalid there even when its value is integral. A number node accepts any finite JSON number, with no range limit, and a float field reads it as a float. Depth and node limits follow the bounds table. A value’s root has depth one, and each array element or object member adds one. A schema’s depth counts schema nodes: the root schema has depth one, and each items, property, or union branch schema adds one, so a schema of depth d describes values of depth at most d. Every scalar, array, and object in a value counts as one node; object keys do not count. JsonSchemaV1 is this bounded JSON Schema subset: Every node MUST contain only the keywords applicable in this table. Duplicate keys, invalid UTF-8 or Unicode surrogates, references, recursion, regular expressions, formats, combinators other than anyOf, conditionals, and unknown keywords are rejected, and so is {"type":"null"} anywhere but as a direct union branch. Every name pattern in this CIP MUST match the entire string. Canonical JSON sorts object keys, so properties carries no order. The order of required is the order in which the model generates fields: a runner MUST present properties to the provider in required order. Union branches keep their schema order. The protocol crate MUST own the schema and value validation used by both runner and node.

Runner execution

A runner executes a structured job as one provider request, like a plain Llm job. It does not retry; an actor that receives a failure decides whether to call again.
  • It resolves the model as Model selection requires.
  • It SHOULD use the serving stack’s native schema-constrained output, such as an OpenAI-compatible response_format of type json_schema with strict set, where the stack supports it, and otherwise a JSON-object mode or prompt instructions. It SHOULD also describe the schema in the system prompt.
  • It sends the stored schema without removing keywords, with properties in required order, inside union branches too, and union branches in schema order. A stack that cannot enforce a keyword may return a value that fails it, which local validation catches.
  • It MUST validate the returned value locally against the complete stored schema, and MUST NOT submit an invalid value as structured.
  • It MUST NOT switch to another model, and MUST NOT use provider-side routing or fallback that can substitute one or that can silently drop the structured-output parameters, such as an OpenRouter request without provider.require_parameters or with a fallback model list.
  • It reports the listed model name it resolved, not a provider-reported model string.
  • It submits a result object for every outcome, including a failure before any provider request, which is provider_error with a null model.
A plain Llm job returns the model’s text as before, with no JSON check. How a runner asks its serving stack for structured output is operator configuration outside this CIP.

Result

The RunnerResult.data of a structured job is one closed JSON object:
A value object contains exactly kind, model, status, v, and value. A failure object contains exactly code, kind, model, status, and v. Unknown fields, statuses, versions, and codes are invalid. value MUST satisfy the stored schema. The failure code set is closed: A value that passes validation is returned whatever the provider’s finish reason. Otherwise the code is chosen in this order: a refusal or provider filtering is provider_refusal; a stop at the token limit is max_tokens_exceeded; crossing a byte or parser-resource bound, the validation-work bound included, is output_too_large; and malformed data within those bounds is invalid_structured_output. The node cannot check which code applies. When a structured job’s result is submitted, the node MUST reject a JobResultSubmit.result whose JSON text repeats a key in any object, comparing keys after unescaping as CanonicalJsonBytes does; this check runs on the submitted bytes because deserialization collapses duplicate keys. The node then MUST decode RunnerResult.data as a structured result and repeat the shape, schema, model, and size checks against the stored request. A result that fails is rejected at submission and does not count toward verification. Because only kind="llm_structured" is admissible, a runner cannot submit a chain-reserved body such as kind="failure" for a structured job. The existing RunnerResult envelope and canonical result identity remain unchanged, and plain Llm results are validated as before. This validation runs for every structured job. It MUST NOT depend on CANONICAL_RESULT_PREIMAGE_ACTIVATION_HEIGHT, QUORUM_CANONICAL_FINGERPRINT_ACTIVATION_HEIGHT (both dormant), or another generic result activation gate. The node computes canonical_result_cbor(RunnerResult.data) directly to measure max_return_bytes, whatever those gates select for result identity.

Terminal failures

A job that ends without a runner result is reported by the chain, not by a runner, as a cowboy-protocol-codec JobFailure body under the callback payload’s failure key. CIP-2 §6.4 (Failure delivery) delivers it to the job’s callback, subject to its drop rules; a dropped one resumes no continuation. It is distinct from a runner’s failure code in failure, and a runner result shaped like a JobFailure is an ordinary result. A structured call’s await behaves like any runner.<job> await:
  • Guarded await: inside a try that names JobFailed, the await raises JobFailed (error code E1621), exposing reason, job_id and attempts.
  • Any other await, including every @runner.continuation method, ends the method: its continuation is deleted, and nothing after the await runs.

Bounds

The lower bound on max_return_bytes leaves room for every failure object, including one that names a 128-byte model. The value bound leaves room for the result object’s five other nodes within the 4,096-node cap on the complete RunnerResult.data. max_return_bytes measures canonical_result_cbor(RunnerResult.data), excluding the one-byte CIP-23 result-preimage version. The runner maps a candidate to output_too_large when its canonical CBOR exceeds max_return_bytes, or when the node’s own result-size check would refuse the JobResultSubmit.result carrying it. That check is shared by the node and the runner. It refuses a result over 63 KiB on the wire, or one too large to store once the node re-serializes it, which can happen for float-heavy data. The node refuses an actually submitted over-bound result with E1623. runner-llm sizes a tee_required result before its attestation is attached. runner-node repeats the check on the final submission, so a single-runner result that only outgrows the bound with its attestation still becomes output_too_large. A JobResultSubmit pays for its result bytes before parsing. The charge is deserialization_base_cycles + result_byte_length * deserialization_per_byte_cycles * passes, with the base charged once: passes is one for a plain result and three for a structured result. The two extra passes cover the duplicate-key scan and canonical CBOR measure. At the deserialization schedule of 20 Cycles base and 1 Cycle per byte, a structured result pays 20 + 3 * result_byte_length. Structured-value validation uses these fixed constants: Work is charged for successful and rejected values using the following accounting, rather than implementation-dependent operation counts: The bounds pass walks the entire value first, depth first, with array elements in array order and object members in ascending UTF-8 key order. Object keys are not value nodes. The schema walk then checks object properties in required order and array elements in array order. A union checks branches in schema order and stops at the first accepting branch; a work-limit error stops the whole validation immediately. Any other bounds or schema error stops its walk, except for a rejected union branch. No other checks add work units. The enum charge is independent of the searched value, a hit or miss, and the chosen lookup algorithm. String-length constraints still count Unicode scalar values; only their work charge counts UTF-8 bytes, rounding down. The structured-value admissibility bound is MAX_VALIDATION_WORK (50,000 units), independent of a submitting transaction’s Cycles limit. A schema-valid value at exactly that bound passes it; a value past it is too costly to check. The runner evaluates this fixed bound without reference to a prospective submission’s remaining Cycles and reports output_too_large when it is exceeded. During transaction execution, the node uses the smaller execution-stop limit min(MAX_VALIDATION_WORK, floor(remaining_Cycles / STRUCTURED_VALIDATION_WORK_UNIT_CYCLES)). Each charge adds its complete units before checking that limit, including when it crosses the limit. The node charges the accumulated work at 2 Cycles per unit before interpreting the validation outcome. If that charge exceeds the transaction’s remaining Cycles, execution fails with OutOfCycles (an ordinary out-of-gas receipt), and the submission cannot persist a result or settle. This resource failure does not establish that the value exceeds the fixed admissibility bound. Result bytes submitted with different signed Cycles limits are different transactions and may have different execution outcomes. A failure result has no value to validate and adds zero validation-work units; the byte passes and result-object checks still apply. Shared protocol/fixtures/cip45/work.json vectors MUST pin exact work totals and acceptance at the limit. With the result bounds, this keeps every structured JobResultSubmit within the 500,000 Cycles budget of any other non-native result.

Verification and settlement

LLM output is generally nondeterministic. Honest runners given the same request cannot normally be expected to produce identical values. For a structured job, admission MUST require verification.mode == EconomicBond and an empty checks list. Committee size and threshold follow the CIP-2 normalization of single-runner modes, which dispatches exactly one runner with threshold one. CIP-23 attestation MAY constrain the selected runner, but it does not make stochastic output deterministic. The submission-time checks above decide result admissibility. They check format, schema, reported model name, and size, not whether the value is true or the reported model actually ran; rejecting a submission does not classify or slash its runner. An admitted structured result, including a failure object, settles as any EconomicBond Llm result: settlement accepts the single result without inspecting it and slashes no runner (whitepaper “LLM Result Verification”, footnote 1). This matches the existing treatment of a plain result that reports a provider error. CIP-45 adds no objective-failure classification. With one runner, a schema-valid value can be fabricated; actor code MUST remain safe under that trust assumption.

Implementation

Delivery is dependency ordered. Node, protocol codec, runner, PVM SDK, and CLI consume one codec revision. Implemented so far:
  • Runner model matching requires structured_output for structured jobs and matches a named model against concrete advertisements, without *.
  • The actor job envelope maps response_schema into the structured intent and uses the llm_structured.v1 result schema for that job.
  • The structured executor resolves the serving model from model_id; LLM_MODEL is only the fallback for an Auto job on a runner advertising only *.
No implementation work is required in CBFS, CBSS, or the gateway.

Compatibility

CIP-45 requires a fresh genesis. It redefines the final field of JobType::Llm and makes no compatibility guarantee for the prior response-model-string form; no activation gate selects between the two forms. An absent response field remains 0x00. At the reviewed version-5 codec baseline, a plain Llm job keeps its bytes, job_spec_hash, req_digest, and JSON, and CIP-45 adds no version increment. The version-5 decoder rejects older JobSpec version bytes independently of schema presence. A plain runner.llm() call sends the same request payload and resumes with the same value, and the existing CowboyModel.schema() output is unchanged. A runner.llm() call that passes a schema string as response_model is refused rather than served as a plain job. Fresh-genesis deployment also covers persisted runner registrations, not only chain state. A runner MUST recreate its local registration store or migrate old rows with an explicit old-record decoder before reusing it. Migration MUST set an absent or untrusted structured_output to false; the operator then re-advertises support against the new registry. A flag decoded at a new position in an old bincode row MUST NOT be treated as an advertised capability; serde(default) does not supply a missing positional field. In the runner-side capability record, structured_output is the final field and MUST serialize even when false, so its bincode round trip does not skip a position. The core runner store’s versioned JSON rows are self-describing; legacy bincode rows clear the flag before reuse. Integration coverage MUST pin this fail-closed decode and capability advertisement to the node’s JSON registration shape. Plain Llm jobs ignore structured_output, so a runner without structured support keeps serving them. A runner that lists only * serves Auto structured jobs when it states structured_output, and no structured job with a named model. CIP-45 adds no other subfeature-negotiation mechanism.

Security considerations

  • Untrusted values: A schema-valid value is not a true value. The model or the selected runner can choose every field adversarially, and actor policy MUST remain safe when it does.
  • Prompt injection: Prompts and returned values never establish identity or authority; actors treat values as data.
  • Fabricated failures: A runner can report any failure code instead of calling a model, and a failure is paid like a value. Accountability is the existing reputation, stake, and timeout machinery.
  • Plain results: The node validates only structured jobs’ results, and the accessors recognize a structured result by its shape. A plain job’s runner can return a structured-looking body that the accessors accept, so actor code reads them only on the result of a structured call. Such a runner gains nothing it could not fabricate as a plain value.
  • Deployed schemas: A model class’s schema is ordinary deployed source. The node validates it like any other response schema when a call submits it, and a hand-edited schema affects only the actor that carries it.
  • Model claims: The reported model is the runner’s claim. Without CIP-23 attestation, an actor cannot prove which model served a job.
  • Sensitive data: Job requests and results are consensus-visible. CIP-45 provides no confidential prompt or result transport.
  • Resource exhaustion: Schema, value, nesting, and result bounds are checked before expensive work. Validating a value against a schema with unions costs at most the value’s nodes times the schema’s nodes, and validation stops at the validation-work bound. Without that bound, a job creator could choose a schema whose valid results cost more to check than a result transaction carries: no runner could submit them, and every runner that served the job would lose its work. Schema derivation runs in cowboy build, off chain, and stops at the schema bounds however model classes reference one another.

Acceptance criteria

CIP-45 is implementation-complete when:
  • plain Llm requests keep identical bytes, hashes, and JSON, pinned by the shared request vectors;
  • the response_schema presence byte, JsonSchemaV1 including number, unions, and the null branch, bounds, and both result forms have shared positive and negative fixtures, and decoders refuse any other presence byte;
  • a structured intent round-trips through normalize, and a client that builds a structured job through JSON checks that the returned bytes carry the schema;
  • a plain runner.llm call sends the same request payload and resumes with the same value as before;
  • cowboy build produces the specified bytes for every row of the derivation table, including float, unions, and typing spellings, keeps required in source order, ignores from __future__ import annotations, records a reason for every rejected class, stops within bounded work on classes whose schema would cross 8 KiB or depth 10, including mutually referencing classes, and appends the model section after the actor’s code;
  • the builder refuses, under response-model-schema, a response_model naming a class without a schema, a class whose name is bound more than once, or a pydantic model;
  • runner and node reject unsupported schemas and invalid values identically, and a value past the validation-work bound is output_too_large at the runner and rejected by the node;
  • shared validation-work vectors pin exact costs for hits, misses, rejected branches, property order, UTF-8 length boundaries, and the work limit, and node and runner reproduce the same totals;
  • the costliest admissible structured result, under the node’s metering, fits the 500,000 Cycles budget of any other non-native result;
  • the runner presents properties in required order and union branches in schema order in every mode, makes one provider request per job, validates every value locally, never switches models, and maps refusals, token-limit stops, context-length errors, provider errors, and timeouts to their codes;
  • the node independently rejects wrong result kinds (including a runner-submitted failure body), values that fail the stored schema, a reported model whose blake3_id differs from a named model_id, oversized results, and results whose JSON text repeats a key, including keys that differ only in escape spelling;
  • with the generic canonical-result activation heights still dormant, the node rejects invalid and over-bound structured results;
  • a structured job, Auto or named, reaches only runners that state structured_output; one with a named model reaches only runners that list the name, is never served by another model, and is never matched by "*", while plain Llm jobs still match "*"; blake3_id("*") is rejected for structured jobs; an Auto result reports a model the runner registered; and a runner refuses to start when its registration states structured_output without a structured executor or with a model list other than its own;
  • structured admission requires the llm_structured.v1 result schema with max_return_bytes in 256..=65,536 and rejects every verification configuration other than EconomicBond without checks;
  • the SDK selects the structured path only for a CowboyModel class or response_schema text, rejects both together, a response_model that is not a CowboyModel class, classes without a derived schema, unknown arguments, verification arguments, and empty or * model names on that path, never downgrades a structured request, and sends the same request from a JobGroup as from an await;
  • normalize supplies the specified result schema;
  • the actor job envelope rejects any response_model, a malformed response_schema, a wrongly typed LLM argument, and timeout_blocks or max_tokens out of range, and never builds a plain job from a structured call;
  • value, parse, failure, and model behave as specified, including float, union, and boolean Literal fields, and raise on a non-structured result, while a chain failure delivered under CIP-2 §6.4 raises JobFailed at a guarded await and ends the method at any other;
  • the developer-interface example, built with cowboy build, compiles and resumes with a parsed value in the consensus execution environment, cold and on a warm interpreter, in a decorated and an undecorated continuation, and under the SDK’s Python compiler;
  • an actor deployed without the builder sends response_schema and reads value as plain data;
  • an account submits a structured job without a callback and reads the result from chain state; and
  • an end-to-end actor receives a node-validated structured value from a named model on an OpenAI-compatible serving stack.

Future work

Actor tools and conversation history

A later CIP extends the Llm job with actor-defined tools and with conversation history across turns, designed together with portable LLM conversations (CIP-44, in draft). Its intended shape:
  • @tool marks an actor method as a tool. The name defaults to the method name and the description to its docstring, and @tool(name=..., description=...) overrides either. Parameters are plain typed parameters or one CowboyModel, with input schemas derived as response schemas are.
  • A turn returns one bounded batch of proposed calls, each with an ID, a tool name, and arguments. The actor authorizes, executes, or refuses each call; the runner never invokes an actor method.
  • A later turn references the earlier turn’s job and carries only the actor’s results for its calls. The node checks the reference (same submitter, completed, matching call IDs), referenced jobs are retained while a turn that references them is live, and any runner rebuilds the conversation from chain state. Actor state and job size then stay independent of conversation length, and consecutive turns can run on different runners.
  • Runners lower tools per serving stack, including a schema-bearing final-answer tool where a stack cannot combine tools with constrained output.

Other work

  • multi-runner agreement over structured values;
  • Enum fields, numeric and length constraints, and model inheritance in derived schemas;
  • $ref and $defs in JsonSchemaV1;
  • CBFS transcript storage and long-term memory;
  • inference caching;
  • general capability negotiation; and
  • confidential prompts and results.

References

OpenAI’s Python SDK infers structured output from a model class passed as the response format, and LangChain’s structured-output interface binds a schema to a model and returns an instance of it. runner.llm() follows the same pattern for response_model, while the chain validates the value and keeps the interface independent of any provider. Industry references are informative: Related Cowboy specifications: