0126: Complete Structured-Output Failure Reports and a Schema-Not-Honored Diagnostic¶
- Status: Accepted
- Author: Chris Colinsky
- Created: 2026-10-11
- Accepted: 2026-10-28
- Targets:
- spec/llm-provider/spec.md: modifies §5 (a
response_schemacall requests and validates conforming output, whether decoding is constrained depends on the path, and aresponse_schemathat declares no$schemais read as JSON Schema draft 2020-12); modifies §7 (thestructured_output_invalidentry's exposure sentence is rewritten, and a definition of whaterror_messagedescribes is added); modifies §7.1 (the Per-attempt span emission paragraph states that each attempt's span, and the call's typed event, describe the request actually sent); modifies §8.1.2 and §8.1.6 (an OpenAI refusal, atomic or streamed, maps to afinish_reasonof"content_filter", and a streamed refusal's pieces are content deltas); modifies §8.1.5.2 (message.contentis the refusal text for a refusal); modifies §8.1.5 (strictdecides the path a call reports, and the sentence on whatstrictleaves unchanged is narrowed to validation); modifies §8.2.5 and §8.3.5 (each states what its provider's constrained decoding guarantees, and names its native path); modifies §8.1.5.1, §8.2.5.1 and §8.3.5.1 (each states that the path a call took is reported on the typed event) - spec/graph-engine/spec.md: modifies §6 (
LlmCompletionEventandLlmFailedEventgainstructured_output_path; under call-level retry, the call's event describes the request of the attempt whose outcome it reports and keeps the node-levelattempt_index;input_messagesis the list as sent, including a prompt-augmentation directive, or the caller's list for a call rejected before sending; the failure event'soutput_contentrow and theerror_messageparagraph after its table point to llm-provider §7) - spec/observability/spec.md: modifies §5.5 (a baseline attribute,
openarmature.llm.structured_output.path); modifies §5.5.2 (an absent request attribute means the field was not supplied for the request the span describes); modifies §5.5.7 (theschema_not_honoreddiagnostic and its record's attributes); modifies §7 (one row in Diagnostic event names); modifies §11 (a caught attempt's duration,error.typeand token-usage samples come from the implementation's record of that attempt, anderror.typeapplies per attempt) - spec/conformance-adapter/spec.md: modifies §5.10 (the observability suite's binding tokens gain
<call_id_X>); modifies §5.1 (definescalls_llm'smodel,response_schema,configandretrykeys, all already in use); modifies §5.5 (mock_llmgains a mapping form carryingcapabilities;log_recordsentries gaincount; newlog_records_absent;otel_observergainsattached; when a case composes a Langfuse observer); modifies §5.11 (documentsmock_provider.capabilitiesand its three flags, and the per-attemptexpected.llm_spansassertion, all already in use); modifies §5.12 and §5.13 (error_message_mentions, and the_mentionsflavor generally, accept a non-empty list, and §5.12'soutput_contententry follows llm-provider §7's definition) - docs/compatibility.md: rows for RFC 6901 and RFC 8259; the JSON Schema row notes that llm-provider §5 now names draft 2020-12; the OpenAI, Anthropic, Gemini and OpenAI streaming rows re-verified, with drift in Gemini's structured-output guide logged
- spec/llm-provider/conformance/021-structured-output-success.md and 022-structured-output-parse-failure.md (sidecar wording only, where they paraphrase §5 and §7)
- spec/llm-provider/conformance/082-structured-output-error-message-completeness.{yaml,md} (new)
- spec/llm-provider/conformance/083-openai-refusal-finish-reason.{yaml,md} (new)
- spec/observability/conformance/161-structured-output-schema-not-honored.{yaml,md} (new)
- Related: 0016 (structured output), 0058 (introduced
error_messageon the typed LLM failure event), 0082 (added the response-side fields to the failure event, and made astructured_output_invalidfailure'serror_messagecarry the §7 description), 0095 (named the §7 error fieldsoutput_contentanderror_message, which itsreaskbuilder consumes by name), 0118 and 0119 (keep the OTel exception record full and ungated, which this proposal leaves in place), 0121 (diagnostic event names)
Summary¶
A structured_output_invalid failure's error_message is required to describe the failure but not all of
it, so an implementation may report only the first violation, and a correction built from it fixes one
field per round trip. This proposal requires every violation, each with its location as a JSON Pointer and
the constraint that failed, and defines what a violation is inside a composite schema. It also records on
the typed LLM event which structured-output path a call took. On a path whose provider constrains decoding,
a response that is not even well-formed JSON, or whose top level is not an object, and that was not cut
short or refused, is then reported as a schema the provider did not apply, rather than passing as an
ordinary model error, and it is diagnosed for every attempt, including one a retry recovers from. Under
call-level retry, the call's typed event and each attempt's span describe the request actually sent.
Motivation¶
A correction can only fix what it is told¶
llm-provider §7 requires the error to expose "a description of the validation or parse failure as
error_message (the wrapped exception's message, the failing JSON Pointer, or the language's
idiomatic equivalent)". The parenthetical describes one failure. Many JSON Schema validators stop at the
first violation by default, so an implementation following the text faithfully reports one violation per
failure.
§7.1's reask builder consumes that description to build the correction the next attempt receives. When
the description names one of several violations, the correction addresses only that one, and the next
attempt fails on the next. A record with several independently wrong fields needs a round trip per field,
against an attempt budget that is usually small. Reporting every violation at once lets one correction
address all of them.
"Every violation" needs a definition of a violation¶
A rule requiring every violation, without saying what one is, can be satisfied unhelpfully. For a union or
composite schema node, anyOf and oneOf in JSON Schema, a validator may report the failure at the
composite:
That names no field and no constraint. It is also the shape of the most common case, since schema
generators commonly render a nullable field as an anyOf with null. A correction quoting it is worse off
than one quoting the single leaf violation it replaced, that lat is not a number.
Resolving to "the member that caused it" is not enough either. When every member of a union fails, no
single member caused it. One implementation reports every member's failures, another a best match,
another only the non-null member, and all three fit the words. Composites also fail in ways no member
explains: a oneOf fails when more than one member matches, a not when its subschema matches, and an
allOf when any member fails, whatever that member's type. So the rule has to say which members count,
and what to report when the composite's own constraint is the one that failed.
The location needs a defined form for the same reason. A description that says "site" or "the third item"
in free prose cannot be told apart from one that reports the composite, or one that reports only the first
array element, so no fixture could check that a violation was resolved at all. JSON Pointer (RFC 6901) is
the standard notation for a location in a JSON document, and the current §7 text already names it as an
example. Its URI fragment form, #/site/lat, settles two details the plain form leaves open. The whole
document is #, rather than an empty string that prints as nothing. And a location in the content does
not occur inside a location in the schema, which validators write as /properties/site/properties/lat,
unless a property is named like a JSON Schema keyword.
A fixture assertion cannot mean one thing today¶
conformance-adapter §5.12's error_message_mentions asserts that the field contains a substring, since the
wording is implementation-defined. Under the current text it can only ever match whichever violation an
implementation surfaces first, and which violation is first is not stable across validators or languages.
A fixture asserting a substring of anything but the sole violation cannot have one answer across
conforming implementations. Requiring every violation, each at a defined location, is what makes the
assertion well defined.
A schema the provider did not apply looks like a model error¶
Each mapping sends the schema to the provider as a native constraint where it can. §8.1.5 sends
strict: true when the schema satisfies OpenAI's strict-mode rules, enabling "schema-constrained
decoding", and falls back to strict: false when it does not. §8.2.5 and §8.3.5 use Anthropic's and
Gemini's constrained decoding.
What a constrained response is promised differs by vendor in its details, but not on syntax: each vendor
documents that a constrained response is well-formed JSON. Full conformance to the schema is promised less
widely. Anthropic documents that the capitalization of string enum and const values is not guaranteed,
on a response that otherwise completes normally, and Gemini documents support for a subset of JSON Schema.
§8.2.5 currently says Anthropic's constrained decoding "guarantees the generated output conforms to the
schema", which overstates the vendor's documented guarantee, and this proposal corrects it.
§8.1.5.1 recognizes that some servers "raise an error or silently ignore the field". It says implementations SHOULD detect this, either statically from provider capability metadata or dynamically from a first call that returns an error, and then fall back to prompt augmentation automatically. The silent case escapes the dynamic arm, since the call succeeds at the transport level, and the static arm catches it only when the capability metadata records the gap.
When an intermediary drops the constraint, the model never sees the schema, because on the native path the
schema is in no other part of the request. Its output is commonly prose, JSON wrapped in a code block, or
JSON of the wrong shape, such as a bare list when the prompt asked for a list. Today that is
indistinguishable from a model that saw the schema and got it wrong. But a constrained response is
malformed only when it was cut short or refused, and each of those already surfaces: a refusal or a
max_tokens truncation as a finish_reason of "content_filter" or "length", and a matched stop
sequence on a request that carried one. A constrained response also always has an object at its top level,
since llm-provider §5 requires every schema to. So content that is not well-formed JSON, or whose top
level is not an object, on an enforced native path, with a finish_reason of "stop" and no stop
sequence on the request, is evidence that the constraint did not reach or did not bind the model.
Content that parses to an object but fails the schema is not such evidence, because a provider that applied the schema can return it, as the capitalization and subset cases show. The diagnostic therefore keys on those two failures alone. A dropped constraint whose output happens to be a well-formed object stays indistinguishable from a model error, as it is today.
One gap in the mappings stands in the way. Anthropic's refusal already maps to "content_filter"
(§8.2.2). OpenAI returns a refusal in a separate refusal field rather than as the message's content, or
in a streamed response as delta.refusal pieces, and neither §8.1.2 nor §8.1.6 maps it, so an OpenAI
refusal reads as unparseable content on a normal finish. This proposal maps it to "content_filter" as
well, and carries the refusal text as the message's content, the way an Anthropic refusal's text already
arrives.
The evidence is already at hand for each attempt except for one fact: which path the request took, and whether that path is enforced. Recording it lets an implementation report the condition, as a diagnostic a caller can act on at the serving layer. It is reported for every attempt, because under reask a later attempt can recover, and diagnosing only the call's final failure would then hide the problem every time.
The diagnostic reports; it does not act. Its condition is evidence, not proof (see the residual cases in Open questions), and acting on it automatically would mean switching to a fallback that writes schema text into the request on the implementation's own initiative. So the condition is defined as distinct from §8.1.5.1's detection, and it never changes what is sent.
A retried call's event and spans can describe another request¶
Under call-level retry (llm-provider §7.1), each wire attempt has its own span, and the call has one typed event, which reports its last attempt's outcome (graph-engine §6; a caught attempt emits no typed event). The spec never says which request either describes. Two features change the request between attempts: a per-attempt override changes the attempt's configuration, and reask adds the model's failed output and the caller's correction to its messages. An implementation may describe the caller's original request everywhere. An attempt's span then reports a temperature that attempt was not sent with, which defeats the point of a span per attempt, and the call's event can describe a request its reported outcome did not come from. This proposal requires each to describe the request actually sent.
What this does to the exception record¶
Requiring every violation lengthens error_message, and a description commonly quotes offending values
from the content. That text reaches two backends differently.
On Langfuse, a failed observation's error_message stays gated by disable_provider_payload and, when the
flag permits it, bounded by observability §8.7's cap. Nothing changes there.
On OTel, the exception recorded on the span stays full and ungated, by deliberate design: observability
§5.5.5 says the record_exception path "is not such a value ... this contract does not reach it", and
§5.5.4 tells callers they see "full exception text in their OTel backend". That record is the surface
observability §8.4's exhaustive-mapping rule relies on for harvested exception detail. This proposal does
not change it. The consequence should be stated plainly: under the default posture, a longer description
means more of the model's content reaches the operator's own OTel backend.
An operator who wants that record bounded already has OpenTelemetry's attribute value length limit. The
OpenTelemetry specification's Attribute Limits section applies it to span event attributes as well as
span attributes, so it bounds the exception's message and its stack trace together, which a cap on the
message alone would not. It is off by default, counts characters rather than bytes, and adds no marker.
An SDK sets it per tracer provider, and the standard environment variable
OTEL_ATTRIBUTE_VALUE_LENGTH_LIMIT sets it across the process. Whether the span-specific
OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT reaches span events differs by SDK: at least one bounds event
attributes with the general limit only, and at least one bounds them with the span limit, read from the
span-specific variable and then the general one. The general variable bounds the exception record in
both when the span-specific variable is unset. Because observability §6 requires openarmature's spans to go through a private tracer provider, the
environment variable is the route that reaches them unless an implementation exposes its provider's limits.
That trade is the operator's to make on their own backend, so this proposal adds no spec-level cap.
Detailed design¶
llm-provider §5: what supplying a schema does¶
In the complete() description, replace "When response_schema is supplied, the call additionally
constrains the model's output to conform to the schema." with:
When
response_schemais supplied, the call additionally requests output that conforms to the schema and validates the response against it (§6parsed, §7structured_output_invalid); whether the provider constrains decoding to the schema depends on the path the mapping takes and on the vendor's own limits (§8).
The old sentence promised conformance on every path, which the paths below do not give.
In the response_schema parameter's description, add after "When present, MUST be a valid JSON Schema.":
A
response_schemathat declares no$schemaMUST be interpreted as JSON Schema draft 2020-12, so that validation, and the violations §7 reports, do not depend on which validator an implementation uses.
A violation is defined by the schema's keywords, and drafts disagree about some of them: items means
something different in 2020-12 than in earlier drafts, dependentRequired first appears in 2019-09, and
prefixItems in 2020-12. So "every violation" has one answer only once the draft is fixed.
docs/compatibility.md already records 2020-12 as the version the spec is written against. A tool's
parameters (§4) is left for a later change, since no fixture here covers it.
llm-provider §7: what the error exposes, and what error_message describes¶
In the structured_output_invalid entry, replace the sentence beginning "The error MUST expose the requested
response_schema" and ending "not a transport failure." with:
The error MUST expose the requested
response_schema; the response's message content asoutput_content, verbatim as the mapping produced it (§6message.content, which for an OpenAI refusal is the refusal text, §8.1.2); a description of the failure aserror_message, defined below; and the response's normalizedfinish_reason(§6) and tokenusage. The last two are available from the received response, since the failure is a downstream parse or validation step on an intact wire response, not a transport failure.output_contentanderror_messagematch the graph-engine §6 failure-event field names (0082), and the call-levelreaskbuilder (§7.1) consumes them.
Then add, as the last paragraph of the entry, after its closing sentence beginning "Distinct from
provider_invalid_response", so it does not separate the exposure sentence from the finish_reason
explanation that follows it:
What
error_messagedescribes.error_messageMUST describe every violation the content commits againstresponse_schema. A violation is a failing assertion, such astype,required,enumorpattern, not the applicator that contains it. Every failing assertion is a violation unless an enclosing keyword discards its result: ananyOforoneOfdiscards its other members' results once one member is satisfied, andif,notandcontainsdiscard their subschemas' results.Each violation MUST state its location and the constraint that failed, and a
typeviolation MUST also state the expected type. The location is a JSON Pointer into the content, written in its URI fragment form (RFC 6901 §6), with the percent-encoding that form requires:#for the whole content,#/site/latfor a property,#/rows/2for an array element, and#/first%20namefor a property namedfirst name.
- Where a violation is located. At the part of the content its failing assertion applies to, with one exception: a property the schema requires but the content lacks (
required,dependentRequired) is located at the pointer it would occupy, so a missinglatinsidesiteis at#/site/lat, and each missing property is its own violation. A property the schema does not allow, such as one excluded byadditionalProperties: false, is one violation at that property's own location. An array element the schema does not allow is one violation at that element. A count or uniqueness constraint on an object or array, such asminItemsoruniqueItems, is one violation at the object or array.- An
anyOforoneOfthat no member satisfies. The violations are every failing assertion of every member whose owntypeadmits the content's type at that location, after resolving references. A member without atypeof its own, including one that is itself a composite, counts as matching. Where no member's type matches, the composite's location and the members' types are reported as one violation.- An
allOf. Every member applies, so the violations are every failing assertion of every member, including a member'stypewhen the content's type does not match it.- A composite whose own constraint fails. A
oneOfthat more than one member satisfies, whatever its other members do, anotwhose subschema is satisfied, and acontainsthat too few or too many array elements satisfy are each one violation, located where the keyword applies.- Content that is not well-formed JSON (RFC 8259). That is the single violation reported. It has no location, since no schema constraint could be evaluated, and in place of a constraint it states what made the content malformed.
Violations SHOULD appear in a stable order for a given content and schema. The wording around each location and constraint is implementation-defined.
The requirement is stated against the content and the schema, not against a validator. An implementation
whose validator stops at the first violation enumerates the rest, and "the first violation" never appears
in the rule. Likewise, a validator that reports locations in another notation, or reports an unexpected
property at its parent object, has its report converted to the form above. response_schema stays a
separately exposed field and is not folded into error_message.
llm-provider §7.1: spans and the event describe the request actually sent¶
In the Per-attempt span emission paragraph, add after its first sentence:
Each attempt's span describes the request that attempt sent, and the call's typed event describes the request of the attempt whose outcome it reports (graph-engine §6).
llm-provider §8.1.2 and §8.1.6: an OpenAI refusal¶
In §8.1.2's finish_reason bullet, add after "Map any unknown finish_reason to error.":
When
choices[0].message.refusalis a non-empty string and the message carries notool_calls, the message's content is the refusal text and thefinish_reasonis"content_filter", whatever value OpenAI reports. This matches the Anthropic mapping, where a refusal's text arrives as content and itsstop_reason: "refusal"maps to"content_filter"(§8.2.2), and it keeps the message within §3.rawkeeps the response as received.
In §8.1.6, add a refusal delta to the Wire bullet's list of what a chunk's choices[].delta
carries, and add to the Content deltas bullet:
A
refusaldelta is a content delta: OpenAI streams a refusal's text there in place ofcontent, so each piece is emitted as a content token event as it arrives.
Add to the bullet beginning "finish_reason / usage":
When the chunks'
choices[].delta.refusalpieces, concatenated, form a non-empty string and the stream carried no tool-call deltas, thefinish_reasonis"content_filter", whatever value the last chunk reports, as for an atomic response (§8.1.2). The refusal pieces are content deltas, so the refusal's text becomes the message's content, as it does for an atomic response that carries no tool calls. The pieces are content whether or not tool-call deltas arrive, since each is emitted as it streams.
Treating the pieces as content deltas keeps §6 Streaming assembly and §5's conformance proxy true as
written: the content is still the ordered concatenation of the content deltas, each emitted as a token
event. The section's verified-shapes sentence gains the refusal delta field. A refusal beside content or
beside tool calls would assemble differently on the two paths; OpenAI documents neither, and
docs/open-questions.md records the case.
In §8.1.5.2, the message.content bullet becomes:
message.contentis the response body's content string, verbatim, or the refusal text when the response is a refusal (§8.1.2).
llm-provider §8.1.5, §8.2.5 and §8.3.5: what each native path guarantees¶
In §8.1.5, replace "The behavioral contract at the spec layer is identical regardless of strict:
validation happens post-receive against response_schema; failures raise structured_output_invalid
(§7)." with:
Validation is identical regardless of
strict: it happens post-receive againstresponse_schema, and failures raisestructured_output_invalid(§7). The path a call reports is not. OpenAI documents schema-constrained decoding only forstrict: true, so the mapping reports a call sent withstrict: trueas the"native"path (graph-engine §6structured_output_path), and one sent withstrict: false, or withoutstrict, as"native_unenforced", whatever a particular OpenAI-compatible server does with the field.
In §8.2.5, replace "Anthropic's constrained decoding guarantees the generated output conforms to the schema." with:
Anthropic's constrained decoding guarantees well-formed JSON with the schema's field types and required properties. It does not guarantee the capitalization of string
enumandconstvalues: a response that differs from the schema only in that capitalization completes normally, withstop_reason: "end_turn", and fails validation. A call on this path reports the"native"path (graph-engine §6structured_output_path).
and replace the paragraph beginning "Two non-conformance cases are inherent to the provider" with:
Three non-conformance cases are inherent to the provider and are NOT validation bugs: a
stop_reason: "refusal"(the refusal takes precedence, so output might not match the schema), astop_reason: "max_tokens"(truncation), and the capitalization case above. In each case the mapping surfaces the non-conforming content and the mappedfinish_reason(content_filter,lengthorstop) per §6 / §7; implementations MUST NOT silently coerce these into a schema-conforming shape.
In the closing note on Anthropic's strict tool use, replace "guarantees tool-call argument conformance" with "constrains tool-call arguments to the tool's input schema, with the same inherent exceptions as above". Anthropic's guide says the capitalization caveat applies to strict tool use too.
In §8.3.5, replace "The response's text content is the JSON string conforming to the schema;" through "The behavioral contract matches §8.1.5's native path." with:
The response's text content is a JSON string; the §8.3 mapping parses it into
Response.parsedand validates againstresponse_schemaper §6. On validation failure, raisestructured_output_invalidper §7. Gemini's constrained decoding produces well-formed JSON, but Gemini documents support for a subset of JSON Schema, so content can fail validation on a call that finished normally. The behavioral contract otherwise matches §8.1.5's native path withstrict: true, and a call on it reports the"native"path (graph-engine §6structured_output_path).
llm-provider §8.1.5.1, §8.2.5.1 and §8.3.5.1: the path is reported¶
Append to each, after its existing requirement that implementations document which path a call uses:
The path a call took is reported on the typed LLM event as
structured_output_path(graph-engine §6), so a caller or observer can inspect it per call.
§8.1.5.1 additionally gains:
A server that silently ignores the native constraint is not detectable from the request. When an attempt on the
strict: truepath returns content that is not well-formed JSON, or whose top level is not an object, and was not cut short or refused, observability §5.5.7'sschema_not_honoreddiagnostic reports it. That diagnostic is not one of the detection arms above, and its condition MUST NOT by itself trigger the fallback.
graph-engine §6: structured_output_path¶
Add one row to both the LlmCompletionEvent and the LlmFailedEvent tables, after request_extras:
| Field | Type | Description |
|---|---|---|
structured_output_path |
string | null | Which path the mapping chose to obtain structured output, when complete() was called with a response_schema (llm-provider §5). "native" when the request carried the schema as the provider's native structured-output constraint in a form that constrains decoding (llm-provider §8.1.5 with strict: true, §8.2.5, §8.3.5). "native_unenforced" when it carried the schema natively in a form that does not (§8.1.5 with strict: false or without strict). "tool_coercion" when it carried the schema as a synthetic tool's input schema (§8.2.5.1). "prompt_augmentation" when the mapping took the prompt-augmentation fallback (§8.1.5.1, §8.2.5.1, §8.3.5.1), whatever the request's extras carry. Where a call issues more than one request, the path of the request whose outcome this event reports. Null when no response_schema was supplied, and null when no request was sent. A classification, not payload: never gated by disable_provider_payload. |
The failure table already states that it mirrors the completion event's request-side fields one for one; the explicit row keeps the table complete.
Replace the paragraph after the failure-event table, which currently describes error_message as "the
wrapped exception's message or failing locator", with:
For a
structured_output_invalidfailure,error_messagecarries the failure description llm-provider §7 defines. Implementations populate the exception message with that description, so observers read it fromerror_messagewithout a dedicated field.
graph-engine §6: the event describes the request its outcome came from¶
Add to the Typed LLM completion event paragraph, after its field table:
Under call-level retry, the event describes the request its outcome came from. Where llm-provider §7.1 issues more than one attempt for a call, the call's single typed event reports its last attempt's outcome, and its
input_messages,request_paramsandrequest_extrasdescribe that attempt's request: the messages it sent, as reask extended them, and the configuration after any per-attempt override is merged. Forrequest_params, "supplied on this call" means supplied for that attempt. The event'sattempt_indexstays the node-level index; the wire attempt's index is on that attempt's span (observability §5.5).
input_messagesis the message list as sent, so on the prompt-augmentation path it includes the schema directive the mapping added (llm-provider §8.1.5.1, §8.2.5.1, §8.3.5.1), as the span'sopenarmature.llm.input.messagesdoes (observability §5.5.1). For a call rejected before any request was sent, it is the caller's message list.
The attempt_index sentence keeps "reports its last attempt's outcome" from being read as moving the
event onto the wire attempt's index, which LlmTokenEvent already rules out for its own field. The
input_messages paragraph settles a reading the old row left open ("the message list the call was made
with"): the span renders from the event and reports what was sent, so the event has to carry the same
list.
LlmFailedEvent inherits the rule through its table's statement that it mirrors the completion event's
request-side fields. Its output_content row, which referred to §7's "raw response content" attribute, now
refers to §7's output_content, the response's message content as the mapping produced it.
observability §5.5: the path attribute¶
Add a bullet to the Baseline attributes list, in that list's existing format, for
openarmature.llm.structured_output.path, a string, saying:
The call's
structured_output_path(graph-engine §6). When aresponse_schemawas supplied and a request was sent, it MUST be set on the LLM provider span; otherwise it MUST NOT be set. A classification, not payload: not gated bydisable_provider_payload.
That list is emitted on every LLM provider span unless the span itself is suppressed, independent of the payload flag, which is why the attribute belongs there rather than in §5.5.1.
observability §5.5.2: request attributes describe the span's own request¶
Replace "(or RuntimeConfig is absent on the call)" with "(or no RuntimeConfig applies to the request
this span describes)", replace "The absence of an attribute means "the field was not supplied for this
call,"" with "The absence of an attribute means "the field was not supplied for the request this span
describes,"", and add:
Under call-level retry each attempt's span describes its own request, after any per-attempt override (llm-provider §7.1).
observability §5.5.7: the schema_not_honored diagnostic¶
Add after the paragraph beginning "With both LlmCompletionEvent and LlmFailedEvent defined", so the
paragraphs about the typed failure event stay together:
A schema the provider did not honor. For each wire attempt of a
complete()call, including an attempt whose failure call-level retry (llm-provider §7.1) catches, the implementation MUST emit exactly oneWARNING-level log record (§7), however many observers are attached, when all of the following hold:
- the attempt failed with
structured_output_invalid, and either the failure is llm-provider §7's parse failure, content that is not well-formed JSON, or the content is well-formed JSON whose top level is not an object;- the attempt's request took the
"native"path (graph-engine §6structured_output_path);- the attempt's
finish_reasonis"stop"; and- the attempt's request carried no stop sequence: no declared
stop_sequences, and none under the stop key of the mapping that issued it in its extras (stopfor llm-provider §8.1,stop_sequencesfor §8.2,stopSequencesfor §8.3). An empty list carries none.The record carries the event name
openarmature.structured_output.schema_not_honored(§7 Diagnostic event names), the §7 correlation fields, and four attributes:
openarmature.llm.provider(string): the call's provider identifier, graph-engine §6'sprovider.openarmature.llm.model(string): the call's model, as the §5.5 baseline attribute of that name.openarmature.llm.call_id(string): the call's graph-engine §6call_id, which joins the record to the call's typed event, including when no span records the attempt.openarmature.llm.attempt_index(int): the attempt's index, the value on the attempt's own span (§5.5).It reports that an attempt on a native path the provider constrains during decoding, which was not cut short or refused, returned content that is not well-formed JSON, or not a JSON object, which a constraint that bound cannot produce, since llm-provider §5 requires an object at the schema's top level. That is evidence the constraint did not reach or did not bind the model. It does not establish why.
The implementation MUST NOT emit the record in any other case. In particular: not for content that parses to a JSON object, whether or not it validates, since a provider that applied the schema can still return content that fails validation (llm-provider §8.2.5, §8.3.5); not for a
finish_reasonof"length"or"content_filter", where the output was cut short or refused; not when the request carried a stop sequence, since a matched sequence also reports"stop"while cutting the output short; and not for astructured_output_pathof"native_unenforced","tool_coercion"or"prompt_augmentation".The record is advisory. Its condition MUST NOT by itself cause the implementation to retry the call, change its structured-output path, or alter any request. It is not one of llm-provider §8.1.5.1's detection arms.
The attributes are keyed so that two implementations write the same record. openarmature.llm.provider
mirrors graph-engine §6's provider in the openarmature.llm.* namespace rather than reusing
gen_ai.system, which upstream has removed and which observability keeps on spans only for the installed
base. openarmature.llm.call_id is the record's join key to the call: the record is emitted whether or not
an OTel observer is attached, so its trace context cannot be relied on to reach the attempt's span.
observability §11: a caught attempt's metric samples¶
In §11's opening paragraph, replace "for errored attempts (the source of an errored attempt's duration
sample and its error.type dimension, §11.3) — and introduce no new data source." with:
for failed calls (the source of a failed call's duration sample and its
error.typedimension, §11.3). An LLM attempt that call-level retry catches has no typed event of its own (graph-engine §6), so its duration sample, itserror.typeand any token-usage samples come from the implementation's record of that attempt, the same data its §5.5 span carries. Metrics introduce no other data source.
The rest of §11 follows. §11.1's "(Both draw from the §6 event stream, which exists regardless of span
emission.)" names the attempt's record as the second source. §11.2's Call-level retry paragraph takes a
structured_output_invalid attempt's usage from the call's LlmFailedEvent.usage when it is the call's
last attempt and from the attempt's record when retry catches it. §11.3's error.type row applies "when
the attempt errored", names the caught attempt's category as a source beside the typed failure events,
and is "Absent on an attempt that succeeded".
observability §7: one row¶
Add to the Diagnostic event names table:
| Event name | Emitted when | Obligation |
|---|---|---|
openarmature.structured_output.schema_not_honored |
§5.5.7, an attempt of a structured-output call on a native path that constrains decoding, not cut short or refused, returned content that is not well-formed JSON or not a JSON object | MUST |
conformance-adapter §5.12 and §5.13: _mentions takes a list¶
Extend the _mentions flavor in §5.13:
- the
_mentionssuffix: the field's value contains the given substring (used where the exact wording is implementation-defined). The value MAY be a non-empty list of substrings, asserting the field contains every one. An empty list isfixture_schema_invalid(§9).
This widens one flavor's value domain. It adds no flavor, so the flavor set stays closed.
In §5.12, the error_message_mentions entry is retyped to <str | [str, ...]> with the same non-empty
rule, and fixtures 082 and 083 join the list of fixtures that use the block. Its output_content entry,
which described the value as "the verbatim content the model produced", now follows §7: the response's
message content as the mapping produced it.
conformance-adapter: keys already in use, path forcing, and log assertions¶
calls_llm.model,calls_llm.response_schema,calls_llm.configandcalls_llm.retry(§5.1). The model the call's provider is bound to, which the typed events report asmodeland the span asopenarmature.llm.model, and the schema, theRuntimeConfigand the retry configuration the call passes tocomplete(). None is new.model, used by 23 observability fixtures, is defined nowhere.response_schemais defined today only by harness notes in the observability fixtures that use it (120, 121, 123, 124, 125, 131 and 155).config, used by fixtures 016, 017, 018, 021, 023, 025, 062, 063, 120, 123, 124, 125, 131 and 155, is defined nowhere.retry, used by fixture 116, has only that fixture's harness note on the streamed mock it pairs with.retrytakes the same mapping as §5.11'scall.retry, including itsper_attempt_overrideandreaskfields. §5.1 becomes the general definition of all four, and where a harness note differs, §5 governs (§5 Definition homes). The definitions carry no list of the fixtures that use them, which would go stale. This closes a small part of the vocabulary gap that 0119's first open question records. Fixture 161 uses all four.mock_provider.capabilities(§5.11, besidemock_provider.responses; §5.11's opening sentence, which describes the section as call-retry directives, gains the mock's capability flags). A mapping of boolean capability flags for the llm-provider mock, each meaning the capability is present when absent:supports_native_response_format, for the OpenAI mapping, andsupports_native_structured_output, for the Anthropic and Gemini mappings. Whenfalse, the mock presents as lacking native structured output, so an implementation takes the mapping's fallback through the static detection arm (§8.1.5.1, §8.2.5.1, §8.3.5.1).supports_image_input. Whenfalse, the mock presents as a model without image input, so a request carrying an image block fails withprovider_unsupported_content_block(llm-provider §7), raised before sending or mapped from the provider's rejection.
None is new: llm-provider fixtures 026, 027 and 072 use the first flag, 040, 041, 050 and 051 the
second, and 016, 035 and 039 the third.
- A mapping form for mock_llm (§5.5). mock_llm also accepts
{capabilities: {...}, responses: [{status, body}, ...]}, mirroring mock_provider. The list form
stays, as shorthand for a mapping with only responses. Since the observability mock presents an
OpenAI-compatible provider, capabilities takes supports_native_response_format, with the meaning
above, so an observability fixture can force the fallback the way the llm-provider fixtures already do.
An absent flag means the capability is present. Each wire request takes the next entry, so under
call-level retry each attempt does, as with mock_provider.responses; §5.5's existing "successive
complete() calls" becomes "successive wire requests" to match.
- count: <int> on an expected.log_records entry (§5.5). The number of captured records the entry
matches MUST equal it. Without count, an entry asserts at least one match, as today.
- log_records_absent: [<entry>, ...] under expected (§5.5). Asserts that no captured log record
matches any listed entry, where an entry matches by the same subset rule as log_records (an entry
naming only event_name matches any record with that event name). The log-record analogue of
attributes_absent and metadata_absent, which exist because a subset match can pin a value but never
an absence.
- otel_observer: {attached: false} (§5.5). Composes no OTel observer for the case; log records and
typed events are still captured. Without it every observability case runs with one, so no case could show
that the diagnostic does not depend on an observer being attached.
- When a case composes a Langfuse observer (§5.5). Today nothing says. A case composes one when it
carries a langfuse_observer or langfuse_client block, an empty langfuse_observer: {} included, or an
expected-outcome assertion on Langfuse output; otherwise it composes none. That matches every existing
observability case: the only case in a Langfuse-named fixture without such a key is fixture 117's
OTel-only case. Without the rule, one adapter could compose Langfuse only for cases that assert its
output and another for every case, and fixture 161's absence and count assertions would mean different
things under each.
- expected.llm_spans (§5.11). The per-attempt span assertion llm-provider fixtures 056, 057, 058 and
061 to 066 already use, with attempt_index, attributes, attributes_absent and error_category,
which §5.11 refers to as existing but nowhere defines. The list is exhaustive and positional, one entry
per span in attempt order, as every existing use already is, and an entry asserts only the keys it
carries, so an entry without error_category asserts neither failure nor success. Fixture 161 uses it
for every span it asserts.
- <call_id_X> (§5.10). A first-occurrence-binding token for a call's graph-engine §6 call_id, added to
the observability suite's set and defined in fixture 161's header, so a case can assert that the
diagnostic record and the call's typed event name the same call.
For any case carrying log_records_absent, or a log_records entry with count, the adapter MUST
capture log records for the whole case and MUST wait until observer delivery has completed before
asserting; an adapter that cannot capture log records MUST fail the case rather than pass it.
Conformance test impact¶
llm-provider 082, structured-output-error-message-completeness (new). Each case returns content that
fails a schema in a known set of ways and asserts error_message_mentions as a list naming every
expected violation's location, in the URI fragment form:
| Shape | Content | Asserted mentions |
|---|---|---|
| Flat object | two required properties missing, one of the wrong type | the three properties' locations, and the expected type's name |
| Nested object | inside site: one property of the wrong type, one missing |
both locations, under #/site/, and the expected type's name |
Nullable field, anyOf with null |
the object member has one property of the wrong type | that property's location only, and the expected type's name |
| Union of two object schemas | each member has a different property of the wrong type | both members' locations, and the expected type's name |
allOf of a referenced object schema and a required list |
site is a string |
#/site, and object |
oneOf of two object schemas |
pet satisfies both members |
#/pet |
Object with additionalProperties: false |
site carries an extra property, zulu |
#/site/zulu |
| A property name with a space | first name has the wrong type |
#/first%20name, and the expected type's name |
| Array of objects | the first and third elements each have a property of the wrong type, the second is valid | #/rows/0/... and #/rows/2/..., and the expected type's name |
| Not valid JSON | error_message_present: true only |
|
Nullable field, anyOf with null |
site is a string, which no member's type admits |
#/site, and both members' types |
dependentRequired |
a property it makes required is missing | the missing property's location |
Array with prefixItems and items: false |
one element more than allowed | that element's location |
Array with minItems |
too few items | the array's location |
Array with uniqueItems |
a repeated item | the array's location |
The assertions name fragment pointers, not property names, which closes three ways a mention could pass
wrongly. A composite-node message names only the composite's location, such as #/site, so it cannot
contain #/site/lat. A message that quotes the schema, or echoes the content, contains property names but
not instance locations. And a schema location, which validators write as /properties/site/properties/lat
or #/properties/site/properties/lat, does not contain an instance location such as #/site/lat, provided
no property is named like a JSON Schema keyword, so the fixture's property names avoid properties,
required, items and the like. Choosing property names that cannot occur as substrings of one another
keeps one location from matching another.
The allOf row is answered by an empty set under a rule that filters allOf members by type, the
oneOf row by an empty set under a rule that reports only failing members' assertions, and the string
site row by an empty set under a rule that filters union members by type and reports only the survivors'
failures, so a mention there separates the rule from those readings. The additionalProperties,
dependentRequired and items: false rows separate it from validators that report the property or
element at its parent, and the space row from an implementation that skips the fragment form's
percent-encoding. The rows that assert a wrong type also assert the expected type's name, which the rule
requires a type violation to state. The dependentRequired and prefixItems rows rely on §5's draft
2020-12 default, since earlier drafts do not define those keywords.
The sidecar records the limits. A mention proves a violation was reported, not that nothing extra was,
because the closed flavor set has no negative form. So four rules are not pinned: that a nullable field's
null member is omitted; that a oneOf over-match is reported whatever its other members do, since every
member's locations share the composite's prefix; the wording of a missing property's constraint; and that
a type-name mention comes from the stated constraint, since a message that quotes the schema contains
type names anyway. A root-level location, #, cannot be asserted by substring either, since every
location contains it, and a location asserted by substring also matches a deeper one under it, so the
union, minItems and uniqueItems rows do not pin that the violation sits at the composite or array
rather than inside it. not and contains are covered by the rule but not by a case.
Existing fixtures 022, 023, 063 and 064 are unaffected: 022 asserts only that a description is present,
and 023, 063 and 064 assert the property name age, which a fragment-form location still contains.
llm-provider 083, openai-refusal-finish-reason (new). Mock OpenAI responses whose message carries a
non-empty refusal and no content:
- OpenAI's own
finish_reasonis"stop", with noresponse_schema. TheResponse'sfinish_reasonis"content_filter", its message's content is the refusal text, andrawcarries the response as received. - The same with OpenAI's
finish_reason"length", so the rule is seen to ignore OpenAI's value. - With a
response_schema, the call raisesstructured_output_invalidwhosecarriesshowfinish_reason"content_filter",output_contentequal to the refusal text,error_message_present: trueandresponse_schema_present: true. - Streamed, in the SSE wire shape fixture 059 uses:
delta.refusalpieces and a last chunk reporting"stop"give afinish_reasonof"content_filter"and the concatenated pieces as the content. - Three cases on the other side of the rule, since OpenAI sends
refusal: nullon ordinary messages: an atomic message with content andrefusal: null, the same withrefusal: "", and a stream whose first chunk carriesrefusal: null. Each keeps its content and"stop", so a mapping that fires on the key's presence fails.
observability 161, structured-output-schema-not-honored (new). Every case asserts the typed event's
structured_output_path through a typed-event collector (typed_observers, contains_event) and the
diagnostic with log_records or log_records_absent. Every case except 14 attaches the OTel observer and
a Langfuse observer; case 14 attaches neither. Every case that expects the record sets a literal
caller_correlation_id and asserts, on a WARNING record, its four attributes and that correlation id,
with its openarmature.llm.call_id bound to the call's typed event; a separate entry counts the records
with the event name at any level. Case 1's response reports a dated model name, so the record must name the
bound model.
Every case but 11 and 14 asserts each attempt's span through llm_spans, with its
openarmature.llm.structured_output.path, or its absence, and its error category. Case 11 asserts no span,
since whether a call rejected before sending gets one is not specified, and case 14 attaches no OTel
observer:
| Case | Path | Outcome | Diagnostic |
|---|---|---|---|
| 1 | native |
content not valid JSON, finish_reason stop |
emitted once (count: 1) |
| 2 | native |
a valid JSON object that fails the schema, stop |
absent |
| 3 | native |
a valid JSON array, stop |
emitted once |
| 4 | native |
content not valid JSON, length |
absent |
| 5 | native |
content not valid JSON, content_filter |
absent |
| 6 | native |
content not valid JSON, stop, stop_sequences set on the call |
absent |
| 7 | native |
content not valid JSON, stop, a stop list only in the call's extras |
absent |
| 8 | prompt_augmentation, forced with supports_native_response_format: false in mock_llm's capabilities |
content not valid JSON, stop |
absent |
| 9 | native |
valid content, on LlmCompletionEvent |
absent |
| 10 | no response_schema |
completion | absent; path null on the event and no path attribute (attributes_absent) |
| 11 | a schema is supplied but the call is rejected before sending, by a conflicting extras response_format (llm-provider §8.1.5) |
provider_invalid_request |
absent; path null on the event |
| 12 | native, with reask |
attempt 0 not valid JSON on stop, attempt 1 valid |
emitted once, carrying openarmature.llm.attempt_index 0; the call's single LlmCompletionEvent reports attempt 1, and its input_messages end with attempt 0's output and the rendered correction |
| 13 | native, with reask and a per_attempt_override that adds stop_sequences for attempt 1 |
both attempts not valid JSON on stop |
emitted once, carrying openarmature.llm.attempt_index 0; the call's single LlmFailedEvent reports attempt 1, and its request_params carry the stop sequence |
| 14 | native, with no backend observer attached (otel_observer: {attached: false}) |
content not valid JSON, stop |
emitted once |
| 15 | native, stop_sequences: [] |
content not valid JSON, stop |
emitted once |
| 16 | native |
an OpenAI refusal, which OpenAI reports as stop |
absent; content_filter and the refusal text on the event |
| 17 | native, streamed |
an OpenAI refusal in delta.refusal pieces, last chunk stop |
absent; the pieces as content token events, and content_filter on the event |
| 18 | native, stop: [] in extras |
content not valid JSON, stop |
emitted once |
| 19 | native, with reask |
both attempts not valid JSON on stop, no stop sequence |
emitted twice, carrying attempt_index 0 and 1 and the same call id; the call's single LlmFailedEvent keeps attempt_index 0 |
The absence cases are what make the emitted cases mean anything. Without them an implementation that
emitted on every structured_output_invalid, or on every native call, would pass. Case 2 pins the
narrowing to malformed and non-object content, case 3 the non-object arm, case 7 the stop sequence carried
in extras, cases 12 and 13 the per-attempt diagnosis and the rule that the call's event describes the
request its outcome came from, cases 15 and 18 that an empty stop list, declared or in extras, suppresses
nothing, cases 16 and 17 that the check reads the mapped finish_reason rather than the one OpenAI
reports, and case 19 one record per qualifying attempt. Cases 12, 13 and 19 also count the call's typed
events and assert the event's node-level attempt_index, and case 13 asserts the stop sequence on attempt
1's span and its absence on attempt 0's. As in fixture 116, none of them sees a typed event for its caught
attempt, so that attempt's record is the only trace of its failure outside its span. Case 8 asserts the
event's input_messages as the list sent: the schema directive as a leading system message, its wording
left to the implementation, then the caller's message. With both
observers attached in every case but 14, an absence case also covers a check placed in either observer, and the
emitted cases' record counts catch an implementation that emits once per observer; case 14, with no
observer attached, catches one that emits only from inside an observer. The schema in cases 1 to 7, 9 and
12 to 19 satisfies OpenAI's strict-mode rules, so the request carries strict: true and the path is
native. That leans on §8.1.5's SHOULD to send strict: true for such a schema, which llm-provider
fixture 026 already requires on the wire, so the case asks nothing the corpus does not.
No existing fixture's cases change. The sidecars of llm-provider fixtures 021 and 022 are reworded where they paraphrase the §5 and §7 text this proposal changes.
Alternatives considered¶
Do nothing. The convergence cost stays, error_message_mentions stays unable to mean one thing across
implementations, and an unhonored schema keeps passing as a model error.
Require only that the first violation be reported, deterministically. It would fix the fixture determinism by defining "first" and leave the round trip per violation in place. It also cannot be specified without naming a traversal order across validators that do not share one.
State the rule as "every violation the validator can enumerate". Rejected: conformance would depend on the validator an implementation chose, and one whose validator stops early would be conformant by construction.
Report only the best-matching member of a union. Rejected: validators disagree on which member is best, so two conforming implementations would report different sets.
Leave the location's form free. Rejected: a fixture could then not tell a leaf from the composite that contains it, or the first array element from the third, so the resolution rule would be untestable.
Write locations as plain JSON Pointer strings. Rejected: the whole content's pointer is the empty
string, which prints as nothing and reads like the no-location form a parse failure uses, and a short
pointer such as /charlie occurs inside a schema location such as /properties/charlie/type, so a fixture
could not tell where a violation was found from where its rule sits in the schema. The URI fragment form
avoids both, at the cost of a # prefix and of percent-encoding for property names with spaces, non-ASCII
characters, or other characters a URI fragment cannot carry.
A structured per-violation field alongside error_message. More useful to a programmatic consumer, and
out of scope here. It would be a new field rather than a change to this one, and nothing here prevents it.
Keep the diagnostic a SHOULD, as the token-budget warning is. Rejected: a fixture could not require it,
so its exclusions could not be tested either, and its condition is determined by facts the implementation
already holds for each attempt, its outcome, path, finish_reason and request, so requiring it costs an
implementation nothing it does not already have.
Key the diagnostic on any validation failure. Rejected: a provider that applied the schema can still return a well-formed object that fails validation, as Anthropic's capitalization caveat and Gemini's subset show, and a MUST that fired on those would report a healthy provider as one that ignored the schema. The cost is that a dropped constraint whose output happens to be a well-formed object goes unreported, as it does today.
Count a call as native only when its schema stays inside the provider's enforced keyword subset. Rejected: it would put each vendor's supported keyword list into the spec, a list that changes as vendors extend support, to recover a case the malformed-or-not-an-object rule already covers in its most common form.
Describe the caller's original request on the event and every span. Rejected: an attempt's span would report settings and messages that attempt was not sent with, which defeats having a span per attempt, and the call's event would describe a request its outcome did not come from.
Emit a typed event per wire attempt, and diagnose from those. Rejected: graph-engine §6 makes the
typed event terminal, one per complete() call, and fixture 116 asserts that a caught attempt emits none.
Changing that is an event-model change for every consumer, the Langfuse mapping included. The
implementation already knows each attempt's outcome, so it can diagnose per attempt without one.
Diagnose only the call's final failure. Rejected: under reask, a later attempt that recovers would hide a dropped constraint every time, and that is the case the diagnostic exists to surface.
Carry enforcement in a separate boolean field. Rejected in favor of one field with four values, which reads more simply and keeps the path and its enforcement from disagreeing.
Detect an unhonored schema and fall back, or add the schema to the next attempt. Rejected: the condition is evidence rather than proof, and acting on it would have the implementation write schema text into the request on its own initiative.
Report the condition from the provider without recording the path on the event. Rejected: it would leave the path invisible to observers and to callers, which §8.1.5.1 already says SHOULD be able to inspect the path chosen.
Bound the OTel exception record in this proposal. Rejected: it would reverse a deliberate decision that keeps that record full, and a cap on the message alone would not bound it, since stack-trace renderings repeat the message. OpenTelemetry's attribute value length limit bounds both, at the operator's choice.
Open questions¶
tool_coercionand the diagnostic. A synthetic tool's input schema is also a constraint, so a coerced call that fails may also mean it was not honored. It is excluded because the failure event'sfinish_reasonfor a coerced call is not defined today: a coerced response stops withtool_calls, which graph-engine §6 says astructured_output_invalidfailure never carries. Including the path waits on that being settled. No fixture asserts thetool_coercionvalue either, since the observability mock presents an OpenAI-compatible provider, which has no such path.- Residual false positives. Anthropic's
pause_turnalso maps to afinish_reasonof"stop"(llm-provider §8.2.2), and the event does not carry the raw stop reason, so a paused turn whose content is incomplete JSON meets the condition without the constraint having been dropped. Whether the Anthropic mapping should surface a paused turn differently is left open. Forwarded stop settings the exclusion does not name, such as a token-id stop list some OpenAI-compatible servers accept, are residual cases of the same kind. native_unenforcedhas no fixture. Forcing it depends on §8.1.5's SHOULD to sendstrict: falsefor a schema outside strict mode, so a case could fail an implementation that conforms.- Langfuse rendering of the path. The path attribute has no §8.4 mapping. It is a classification
rather than harvested content, so the exhaustive-mapping rule does not require one, and a Langfuse user
loses only the inspection surface. Mapping it to
generation.metadatais cheap if wanted. - Verify before accept. The diagnostic rests on vendor statements: that each native path constrains
decoding to well-formed JSON with an object at the top level, that OpenAI's does so only with
strict: true, Anthropic's capitalization caveat, and Gemini's support for a subset of JSON Schema. §8.1.2's and §8.1.6's refusal rule rests on the shape of an OpenAI Chat Completions refusal: therefusalfield and thedelta.refusalpieces, whether content accompanies a refusal, and thefinish_reasonOpenAI reports with it. And the OpenTelemetry specification applies the attribute value length limit to span events, which at least one SDK implements; coverage across the SDKs implementations use should be checked. Each should be confirmed against current documentation before accept. - Forcing the fallback rests on a SHOULD. Case 8 reaches prompt augmentation through §8.1.5.1's static detection, which the spec states as a SHOULD, and llm-provider fixture 027 already treats it as required ("the implementation MUST fall back per §8.1.5.1"). Whether static detection becomes a MUST waits on a prior question. The fallback appends a schema directive the implementation writes, which sits uneasily with the charter's principle 7, "No built-in prompts": every prompt the LLM sees is authored by the developer. Making the fallback mandatory would harden that tension before it is settled.