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 throughrunner.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::Llmcarries a model hash, prompt, system prompt, temperature, token limit, and a final optional response-model string;JOB_SPEC_VERSIONis3.- 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
EconomicBondwith one runner, synthesizes a result schema withdata_format="json", and readsrunner.llmkeyword arguments by name, silently dropping unknown ones and some wrongly typed ones. - The RPC returns
job_typeas JSON; the python-sdk builds jobs by passing JSON to the codec’snormalize, and the binding parses it withserde_jsonwithout rejecting unknown fields. - The executor ignores
model_id, servesLLM_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
RunnerResultand does not enforcemax_return_bytes. EconomicBondsettlement 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 inRunnerResult. - The registry requires listed models for runners advertising
llm.
Goal
CIP-45 provides:- schema-validated structured output without actor-side text parsing;
- node validation of every structured result against the request’s schema;
- explicit model selection, either an exact model or a runner-chosen one, with the serving model reported;
- a closed set of failure codes an actor can act on;
- no additional continuation state, and schemas derived when the actor is built rather than on every call; and
- unchanged behavior for plain
Llmjobs and plainrunner.llm()calls.
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 structuredLlm 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 extendJobType::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_schemasatisfiesJsonSchemaV1;promptis non-empty;max_tokensis in1..=JobSpec.bounds.max_output_tokens;model_idis notblake3_id("*");result_schemais present withdata_format="llm_structured.v1"and256 <= max_return_bytes <= 65,536; and- the verification profile is the one in Verification and settlement.
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 reusesLlm.model_id:
model_id == blake3_id("")requestsAuto: the runner chooses one model it serves that supports structured output. The choice is operator policy and need not be deterministic.- Any other
model_idnames a model: the runner MUST serve the listed model whoseblake3_idequalsmodel_id, and MUST NOT substitute another model or a process-wide default.
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 plainLlm
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_formatof typejson_schemawithstrictset, 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
requiredorder, 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_parametersor 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_errorwith a nullmodel.
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
TheRunnerResult.data of a structured job is one closed JSON object:
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 acowboy-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
trythat namesJobFailed, the await raisesJobFailed(error codeE1621), exposingreason,job_idandattempts. - Any other await, including every
@runner.continuationmethod, 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 requireverification.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_outputfor structured jobs and matches a named model against concrete advertisements, without*. - The actor job envelope maps
response_schemainto the structured intent and uses thellm_structured.v1result schema for that job. - The structured executor resolves the serving model from
model_id;LLM_MODELis only the fallback for anAutojob on a runner advertising only*.
Compatibility
CIP-45 requires a fresh genesis. It redefines the final field ofJobType::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
Llmrequests keep identical bytes, hashes, and JSON, pinned by the shared request vectors; - the
response_schemapresence byte,JsonSchemaV1includingnumber, 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.llmcall sends the same request payload and resumes with the same value as before; cowboy buildproduces the specified bytes for every row of the derivation table, includingfloat, unions, andtypingspellings, keepsrequiredin source order, ignoresfrom __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, aresponse_modelnaming 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_largeat 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
requiredorder 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
failurebody), values that fail the stored schema, a reported model whoseblake3_iddiffers from a namedmodel_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,
Autoor named, reaches only runners that statestructured_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 plainLlmjobs still match"*";blake3_id("*")is rejected for structured jobs; anAutoresult reports a model the runner registered; and a runner refuses to start when its registration statesstructured_outputwithout a structured executor or with a model list other than its own; - structured admission requires the
llm_structured.v1result schema withmax_return_bytesin256..=65,536and rejects every verification configuration other thanEconomicBondwithout checks; - the SDK selects the structured path only for a
CowboyModelclass orresponse_schematext, rejects both together, aresponse_modelthat is not aCowboyModelclass, 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 aJobGroupas from an await; normalizesupplies the specified result schema;- the actor job envelope rejects any
response_model, a malformedresponse_schema, a wrongly typed LLM argument, andtimeout_blocksormax_tokensout of range, and never builds a plain job from a structured call; value,parse,failure, andmodelbehave as specified, includingfloat, union, and booleanLiteralfields, and raise on a non-structured result, while a chain failure delivered under CIP-2 §6.4 raisesJobFailedat a guarded await and ends the method at any other;- the developer-interface example, built with
cowboy build, compiles and resumes with a parsedvaluein 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_schemaand readsvalueas 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 theLlm job with actor-defined tools and with
conversation history across turns, designed together with portable LLM
conversations (CIP-44, in draft). Its intended shape:
@toolmarks 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 oneCowboyModel, 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;
Enumfields, numeric and length constraints, and model inheritance in derived schemas;$refand$defsinJsonSchemaV1;- 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: