> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cowboy.inc/llms.txt
> Use this file to discover all available pages before exploring further.

# CIP-45: Structured LLM Output

> Schema-validated LLM results and explicit model selection on the existing LLM job

> [!NOTE]
>
> **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)

> [!NOTE]
>
> **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:

```python theme={null}
from typing import Annotated, Literal, Optional

from cowboy_sdk import Actor, CowboyModel, public, runner


class Decision(CowboyModel):
    """Answer to one customer support question."""

    reasoning: Annotated[str, "Short justification, written before the answer"]
    answer: str
    confidence: Literal["low", "medium", "high"]
    order_id: Optional[str]


class SupportDesk(Actor):
    @public
    @runner.continuation
    async def answer(self, question):
        res = await runner.llm(question, model="support-model-v1", response_model=Decision)
        if res.value is None:
            return f"no answer: {res.failure}"
        return res.value.answer
```

`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:

| Accessor | Behavior |
| - | - |
| `value` | On a `StructuredResult`, the instance of its class, built on the first read and returned by every read. On any other result, the value as plain data. `None` for a runner failure. |
| `parse(T)` | For a structured value, an instance of `T` built from the value, including nested models; raises if the value does not fit `T`. For a runner failure, `None`. |
| `failure` | The code of a runner failure; otherwise `None` |
| `model` | The serving model's name, or `None` when the result names none |

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.

| Field annotation | Schema node |
| - | - |
| `str` | `{"type":"string"}` |
| `int` | `{"type":"integer"}` |
| `float` | `{"type":"number"}` |
| `bool` | `{"type":"boolean"}` |
| `Literal[v1, ..., vn]` whose values are distinct string literals, distinct integer literals (optionally negated) in the safe-integer range, or distinct boolean literals | that type, with `enum` listing the values in declaration order |
| `list[T]` or `List[T]` | `{"type":"array","items":<T>}` |
| The name of another model class in the module | the object node for that class |
| `Optional[T]`, `Union[...]`, or members joined with the union operator | the union node described below |
| `Annotated[T, "text"]` at any position, with exactly one string-literal metadata argument | the node for `T` with `description` `"text"`, replacing a model class's docstring |

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:

| Presence byte | Meaning | Wire |
| - | - | - |
| `0x00` | Plain job, no response schema | Nothing follows |
| `0x01` | Structured job | The length-prefixed canonical schema follows |

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:

| Scope | Accepted form |
| - | - |
| Type | Every node other than a union has `type` equal to `boolean`, `string`, `integer`, `number`, `array`, or `object`, or exactly `[T, "null"]` for one of those; the root is a non-nullable object |
| Object | `properties`, `required`, and `additionalProperties` are all present; `properties` is non-empty, with unique names matching `[A-Za-z_][A-Za-z0-9_]{0,63}`; `required` lists every property exactly once; `additionalProperties` is exactly `false` |
| Array | Exactly one `items` schema; optional non-negative integer `minItems` and `maxItems`, with minimum not greater than maximum |
| String | Optional non-negative integer `minLength` and `maxLength` count Unicode scalar values, with minimum not greater than maximum |
| Integer | Optional safe-integer `minimum` and `maximum`, with minimum not greater than maximum |
| Number | No keyword other than `type` and `description` |
| Union | `{"anyOf":[S1, ..., Sn]}` with `2 <= n <= 8`, optionally with `description` and no other keyword; never the root; no branch is itself a union, though a union may sit deeper inside a branch's `properties` or `items`; a value is valid when at least one branch accepts it, and branches may overlap |
| Null branch | `{"type":"null"}`, optionally with `description`, only as a direct union branch and at most once per union; a nullable-typed branch may sit beside it |
| Common | Optional string `description`; on `boolean`, `string`, and `integer` nodes only, at most one of non-empty unique `enum` or `const`; every constrained value matches the node type, and the `enum` of a nullable node includes `null` |

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:

```json theme={null}
{"kind":"llm_structured","model":"support-model-v1","status":"ok","v":1,"value":{"answer":"Shipped","confidence":"high","order_id":"A-42","reasoning":"Tracking shows delivery"}}
{"code":"unsupported_feature","kind":"llm_structured","model":null,"status":"non_success","v":1}
```

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:

| Code | Meaning |
| - | - |
| `provider_refusal` | The model refused, or the provider filtered the output |
| `invalid_structured_output` | The value failed the stored schema |
| `output_too_large` | A candidate result exceeded a result bound |
| `max_tokens_exceeded` | Generation stopped at `max_tokens` before a complete value |
| `context_length_exceeded` | The request exceeded the model's context window |
| `unsupported_feature` | The requested model cannot serve the schema |
| `provider_error` | The provider or adapter failed before an outcome |
| `provider_timeout` | The provider did not return before the deadline |

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

| Item | Bound |
| - | -: |
| Canonical `JobSubmit.job_spec` | existing 256 KiB cap |
| Raw `JobResultSubmit.result` | 63 KiB on the wire, and its stored form within one 64 KiB storage value |
| CIP-23 `canonical_result_cbor(RunnerResult.data)` | `max_return_bytes`, `256..=65,536` |
| Complete `RunnerResult.data` | existing `cowboy-protocol-codec` 4,096-node cap |
| User prompt / system prompt | existing `Llm` caps, 128 KiB UTF-8 each |
| Model name | 128 bytes UTF-8 |
| Response schema | 8 KiB canonical JSON, depth 10 |
| Union branches | `2..=8`, a null branch included |
| Structured value | depth 10, 4,091 nodes |
| Validation work for one structured value | 50,000 work units |
| `max_tokens` through the actor job envelope | 32,768 |
| `timeout_blocks` through the actor job envelope | `10..=600`, default 30 |

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:

| Constant | Value |
| - | -: |
| `STRUCTURED_RESULT_EXTRA_BYTE_PASSES` | 2 |
| `STRUCTURED_VALIDATION_WORK_UNIT_CYCLES` | 2 |
| `STRING_BYTES_PER_WORK_UNIT` | 32 |
| `MAX_VALIDATION_WORK` | 50,000 |

Work is charged for successful and rejected values using the following
accounting, rather than implementation-dependent operation counts:

| Validation step | Work units |
| - | -: |
| Each value node visited by the bounds pass, after its depth and node-count checks pass | 1 |
| Each schema-node/value-node check entered, including a union node, a failed branch, or a wrong type | 1 |
| An object schema checked against an object value, before checking missing or unknown properties | Number of declared properties |
| A non-null string whose type matches and whose schema has `minLength` or `maxLength`, before testing those bounds | `floor(UTF-8 byte_length / STRING_BYTES_PER_WORK_UNIT)` |
| An `enum` or `const` membership lookup reached after the applicable type and range checks | `1 + ceil(log2(n))`, where `n` is the number of allowed values; `const` has `n = 1` |

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.

| Order | Component | Required change |
| -: | - | - |
| 1 | Cowboy CIPs | Amend CIP-2 runner capabilities with `structured_output`, and CIP-2 model advertisement so `*` does not match a structured job's named model; state in CIP-6 §13 that structured `runner.llm` calls accept only the CIP-45 verification profile |
| 2 | protocol codec | The presence-tagged `response_schema` field on `JobType::Llm` and the `Llm` job intent, the `normalize` default, the `validate()` rules, `JsonSchemaV1` and value validation, result-object validation, bounds, and shared request, schema, value, result, derivation, and validation-work vectors |
| 3 | node | Structured admission, the `structured_output` registration flag and the structured branch of the `Llm` model filter, the actor job envelope for `response_schema` and per-call limits, and result validation at submission |
| 3 | runner | A structured branch in the LLM executor that resolves the model, lowers the schema for the serving stack, validates and normalizes the value, and maps failures; the `structured_output` capability and the startup check of a registration that states it; plain `Llm` execution without a JSON check |
| 3 | CLI and PVM compiler | Schema derivation in `cowboy build`, the model section it appends, and the `response-model-schema` rule |
| 3 | PVM SDK | The send-step mapping from `response_model` to `response_schema`, the `response_schema` path, the resume-leg `StructuredResult` binding in the compile-time transform and the SDK's Python compiler, and the `RunnerResult` accessors |
| 3 | `python-sdk` | Structured request builders, the check that returned bytes carry the schema before submission, and result decoding for jobs submitted from an account |
| 4 | all consumers | Run shared request, schema, value, result, derivation, and validation-work vectors |
| 5 | deployment | Runner operators list concrete model names so structured jobs can name a model; networks launch with CIP-45 at a fresh genesis |

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:

* [OpenAI structured outputs](https://developers.openai.com/api/docs/guides/structured-outputs)
* [LangChain structured output](https://docs.langchain.com/oss/python/langchain/structured-output)

Related Cowboy specifications:

* [CIP-2: Verifiable Off-Chain Compute](https://docs.cowboy.inc/cips/cip-2-offchain-compute.md)
* [CIP-6: In-PVM Actor SDK](https://docs.cowboy.inc/cips/cip-6-sdk.md)
* [CIP-11: Runner Connectivity and Push Job Delivery](https://docs.cowboy.inc/cips/cip-11-runner-connectivity.md)
* [CIP-23: TEE Execution and Composite Attestation](./cip-23-tee-execution.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.