Changing Structured-Output Constraints
Structured output is a five-stage pipeline:
@AiService return type
→ compiled StructuredTypeDescriptor
→ JSON schema
→ shape validation (pre-deserialize)
→ Jackson deserialize
→ value validation (post-deserialize)
Constraints (@AiRange, @AiMinItems, @AiDescription, nullability) are compiled at descriptor build time, then enforced by StructuredJsonShapeValidator and StructuredValueValidator. Changing a constraint means changing the compiler, the schema renderer, and the validators together — and the TCK pins every failure stage.
The Extension Points
| Contract | Where |
|---|---|
StructuredOutputHandler — createContract, analyze, generateSchema, deserialize, serialize | tramai-core/src/main/kotlin/dev/tramai/core/structured/StructuredOutputHandler.kt |
StructuredOutputResult — Success(value, rawResponse) / Failure(rawResponse, errorSummary, feedbackMessage) | same package |
StructuredOutputFailureCode — CONTRACT_FAILED, OUTPUT_REJECTED, REPAIR_EXHAUSTED, HANDLER_FAILED | same package |
Descriptor model — sealed Scalar, Enum(values), Collection(item, minItems), Object(typeName, properties); StructuredPropertyDescriptor(name, type, required, description, range, accessor); NumericRange | tramai-structured/src/main/kotlin/dev/tramai/structured/descriptor/StructuredTypeDescriptor.kt |
| Constraint compile point | descriptor/KotlinStructuredTypeCompiler.kt (compileProperty); JavaBean counterpart descriptor/JacksonJavaBeanStructuredTypeCompiler.kt |
| Schema rendering | descriptor/StructuredSchemaRenderer.kt (emits additionalProperties: false) |
| Shape validation | descriptor/StructuredJsonShapeValidator.kt |
| Value validation | descriptor/StructuredValueValidator.kt |
| Contract fingerprint | descriptor/StructuredContractFingerprint.kt — SHA-256 over the descriptor, deliberately excluding typeName and accessors so Kotlin and JavaBean descriptors agree |
| Descriptor cache | descriptor/StructuredDescriptorCache.kt |
Annotations live in tramai-core/src/main/kotlin/dev/tramai/core/annotations/: AiRange.kt, AiMinItems.kt, AiDescription.kt.
Files That Change Together
| Constraint | Compile | Schema | Shape-validate | Value-validate |
|---|---|---|---|---|
@AiRange | KotlinStructuredTypeCompiler.kt | StructuredSchemaRenderer.kt | — | StructuredValueValidator.kt |
@AiMinItems | KotlinStructuredTypeCompiler.kt (collection-only) | renderer minItems | StructuredJsonShapeValidator.kt (array-ness) | StructuredValueValidator.kt |
| required / nullable | compileProperty (required = !nullable) | renderer required list | StructuredJsonShapeValidator.kt | StructuredValueValidator.kt |
| a new annotation | new key in the descriptor + compiler | renderer | validator | validator |
Changing the descriptor also changes the fingerprint, and therefore the cache key.
The Failure-Stage Taxonomy
FailureStage in tramai-structured/src/test/kotlin/dev/tramai/structured/tck/StructuredOutputContractCase.kt has exactly these stages:
EXTRACTION · JSON_PARSE · SHAPE · DESERIALIZATION · VALUE_VALIDATION
A new constraint must map to exactly one stage. Do not blur them — the TCK asserts the declared stage for each invalid case.
Mandatory Contract Tests
| Test | Where |
|---|---|
StructuredOutputContractTck — lifecycle verifier: compile failure, schema, valid round-trip, per-stage failure pinning, SHAPE-versus-VALUE_VALIDATION direct proof, deterministic repair | tramai-structured/src/test/kotlin/dev/tramai/structured/tck/StructuredOutputContractTck.kt |
StructuredOutputContractCase — the case model and FailureStage enum | same package |
JacksonStructuredOutputContractTckTest — concrete fixtures (required string, nullable, nested, root array, enum regression) | same package |
JacksonStructuredOutputHandlerContractEvolutionTest — field evolution: a new required field breaks old JSON; @AiRange / @AiMinItems behaviour | tramai-structured/src/test/kotlin/dev/tramai/structured/ |
StructuredContractFingerprintEvolutionTest | .../structured/tck/ |
StructuredDescriptorSchemaValidationAgreementTest — descriptor and rendered schema agree | .../structured/descriptor/ |
StructuredTypeDescriptorJavaBeanParityTest | .../structured/descriptor/ |
StructuredDescriptorArchitectureTest, StructuredDescriptorCacheTest, StructuredContractFingerprintTest | .../structured/descriptor/ |
JacksonStructuredOutputHandlerTest, JacksonStructuredOutputHandlerBoundaryTest, JavaBeanStructuredOutputHandlerTest | .../structured/ |
Compatibility
- Constraint semantics are the contract. Changing
@AiRangebounds or@AiMinItemssilently breaks downstream consumers. Extend the evolution test deliberately; do not relax it. - Fingerprint changes alter cache keys and repair semantics. The fingerprint is what the descriptor cache is keyed on.
- The rendered schema is what clients compile against. Descriptor and schema must agree, which
StructuredDescriptorSchemaValidationAgreementTestpins. - Kotlin/JavaBean parity. Descriptor fingerprints exclude
typeNameand accessors precisely so both compilers produce identical descriptors. Do not introduce a Kotlin-only constraint. - ABI.
StructuredOutputResultandStructuredOutputFailureCodeshapes are stable public API. Changing the three-argumentFailureconstructor or the failure codes is apublic-api-classified change. - Cache safety.
createContractis cached per handler, so a new constraint annotation must be safe under concurrentConcurrentHashMapaccess.
Engine Boundary
StructuredResponseCoordinator in tramai-engine/structured consumes the handler contract — constraint semantics do not live there. It owns failure classification: a missing handler becomes a ConfigurationException; parse and validation failures become OUTPUT_REJECTED with a safe summary; the repair loop appends the raw response as an assistant turn and the feedback message as a user turn, terminating with REPAIR_EXHAUSTED; handler exceptions are always terminal HANDLER_FAILED; contract failures are CONTRACT_FAILED.
Verification
./gradlew :tramai-structured:test \
--tests '*StructuredOutputContractTck*' \
--tests '*ContractEvolution*' \
--tests '*SchemaValidationAgreement*'
./gradlew :tramai-engine:test --tests '*StructuredResponseCoordinator*'
./gradlew verifyPr
./gradlew verifyChangePolicy -PchangeClass=public-api # when annotations or contracts change
What Not To Change In This Pull Request
StructuredResponseCoordinator— it consumes the contract; constraint semantics do not live there.- The TCKs — they pin the failure stages.
- Kotlin/JavaBean parity — do not add a Kotlin-only constraint.
config/quality/0.6.0-baseline.json.
Common Mistakes
| Symptom | Cause |
|---|---|
| JavaBean parity test fails, fingerprint differs | The constraint was added to the Kotlin compiler only |
| Schema agreement test fails | The constraint was added without updating StructuredSchemaRenderer |
| TCK fails on stage assertion | The new constraint was mapped to no stage, or to more than one |
| Fingerprint evolution test fails | Expected when the descriptor changes — update the expectation deliberately |
| Repair loop behaviour changes unexpectedly | Cache key changed because the fingerprint changed |
