ADR-083 — JSON-typed schema fields compile to a type union, not the empty schema
- Status: accepted
- Date: 2026-07-22
- Deciders: devthejo
Context
The DSL json field type (ir.FieldTypeJSON) means "this output field accepts any JSON value" — object, array, string, number, boolean, or null. SchemaToJSON (pkg/backend/model/schema.go) compiles each IR schema into the JSON Schema every delegate hands to its provider as the structured-output contract (task.OutputSchema, consumed by claude_code, claw, and codex alike — there is a single conversion point).
The canonical JSON Schema for "any value" is the empty schema {} (no type key). Two live production bugs, pulling in opposite directions, proved that neither {} nor a single concrete type is a safe compilation for a strict structured-output pass:
A single
"type": "object"rejects every non-object shape. Seen on secured-renovacy/main.bot (run_1778786106222 sonnet+high, run_1778784391171 opus+max):detect_stackpopulated a recipe'secosystems: jsonfield as a JSON array (the only sensible shape for "list of per-ecosystem profiles"). The derived schema declared{"type": "object"}, JSON Schema rejected the array, the formatter stripped the value to nothing →raw_output_len: 0+ "missing required field ecosystems".The empty schema
{}(notypekey) is rejected outright by OpenAI/codex's structured-output formatting pass withinvalid_json_schema: In context=('properties', <field>), schema must have a 'type' key— 400ing the whole node, surfacing asdelegate: codex formatting pass returned empty structured output→failed_resumable, even when the agent's own final message carried a valid payload. Anthropic tolerates{}; OpenAI does not.
iterion validate accepted the schema silently in both cases — the failure only appeared at the first real execution against the provider.
Decision
Compile a FieldTypeJSON property to a type union over every JSON kind:
{ "type": ["object", "array", "string", "number", "boolean", "null"] }This satisfies both constraints simultaneously: the type key is present (bug 2 — providers that require one accept it), and every JSON shape remains valid (bug 1 — arrays/scalars/null are not stripped). It is the faithful wire encoding of FieldTypeJSON's "accepts any value" contract for providers that reject the type-less {}.
Alternative rejected — flag/reject json fields at validate time
The feature admitted a second reading: reject or warn on json-typed fields in LLM-node output schemas at compile time ("json fields are not representable in strict structured output — use a typed field"). We did not take this path:
jsonis a legitimate, shipped field type used by real recipes (ecosystems, free-formquestionspayloads) where the value genuinely has no fixed shape. Rejecting it would force authors into lossystring[]workarounds for data that is not a string array.- The union makes the schema valid, so the validate-time silent acceptance is now correct behaviour, not a latent trap — there is nothing left to flag.
A provider-specific branch (emit {} for Anthropic, the union for OpenAI) was also rejected: the union is valid for both, so one encoding keeps SchemaToJSON provider-agnostic — the same compiled contract behaves identically on either backend, matching the "a bot behaves identically on either backend" invariant the rest of the stack upholds.
Consequences
- A
jsonoutput field is portable across all three delegates with no per-provider handling. - The compiled property is permissive (any JSON kind) — this is intentional;
FieldTypeJSONis the author's explicit opt-out of shape constraints. Authors who want a shape use a typed field or a nested schema. - Pinned by
TestSchemaToJSON_JSONFieldIsAnyType(pkg/backend/model/schema_test.go), which asserts both directions: thetypekey is present (bug 2) and the union admits every JSON kind includingarray(bug 1). Re-narrowing to a single type or dropping the key will fail the test.
