TramAI - governed AI workflows for Java and Kotlin

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

ContractWhere
StructuredOutputHandler — createContract, analyze, generateSchema, deserialize, serializetramai-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_FAILEDsame package
Descriptor model — sealed Scalar, Enum(values), Collection(item, minItems), Object(typeName, properties); StructuredPropertyDescriptor(name, type, required, description, range, accessor); NumericRangetramai-structured/src/main/kotlin/dev/tramai/structured/descriptor/StructuredTypeDescriptor.kt
Constraint compile pointdescriptor/KotlinStructuredTypeCompiler.kt (compileProperty); JavaBean counterpart descriptor/JacksonJavaBeanStructuredTypeCompiler.kt
Schema renderingdescriptor/StructuredSchemaRenderer.kt (emits additionalProperties: false)
Shape validationdescriptor/StructuredJsonShapeValidator.kt
Value validationdescriptor/StructuredValueValidator.kt
Contract fingerprintdescriptor/StructuredContractFingerprint.kt — SHA-256 over the descriptor, deliberately excluding typeName and accessors so Kotlin and JavaBean descriptors agree
Descriptor cachedescriptor/StructuredDescriptorCache.kt

Annotations live in tramai-core/src/main/kotlin/dev/tramai/core/annotations/: AiRange.kt, AiMinItems.kt, AiDescription.kt.

Files That Change Together

ConstraintCompileSchemaShape-validateValue-validate
@AiRangeKotlinStructuredTypeCompiler.ktStructuredSchemaRenderer.kt—StructuredValueValidator.kt
@AiMinItemsKotlinStructuredTypeCompiler.kt (collection-only)renderer minItemsStructuredJsonShapeValidator.kt (array-ness)StructuredValueValidator.kt
required / nullablecompileProperty (required = !nullable)renderer required listStructuredJsonShapeValidator.ktStructuredValueValidator.kt
a new annotationnew key in the descriptor + compilerrenderervalidatorvalidator

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

TestWhere
StructuredOutputContractTck — lifecycle verifier: compile failure, schema, valid round-trip, per-stage failure pinning, SHAPE-versus-VALUE_VALIDATION direct proof, deterministic repairtramai-structured/src/test/kotlin/dev/tramai/structured/tck/StructuredOutputContractTck.kt
StructuredOutputContractCase — the case model and FailureStage enumsame 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 behaviourtramai-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 @AiRange bounds or @AiMinItems silently 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 StructuredDescriptorSchemaValidationAgreementTest pins.
  • Kotlin/JavaBean parity. Descriptor fingerprints exclude typeName and accessors precisely so both compilers produce identical descriptors. Do not introduce a Kotlin-only constraint.
  • ABI. StructuredOutputResult and StructuredOutputFailureCode shapes are stable public API. Changing the three-argument Failure constructor or the failure codes is a public-api-classified change.
  • Cache safety. createContract is cached per handler, so a new constraint annotation must be safe under concurrent ConcurrentHashMap access.

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

SymptomCause
JavaBean parity test fails, fingerprint differsThe constraint was added to the Kotlin compiler only
Schema agreement test failsThe constraint was added without updating StructuredSchemaRenderer
TCK fails on stage assertionThe new constraint was mapped to no stage, or to more than one
Fingerprint evolution test failsExpected when the descriptor changes — update the expectation deliberately
Repair loop behaviour changes unexpectedlyCache key changed because the fingerprint changed