Skip to content

JSON Schema

View Markdown

Status: Stable Package: dev.vertique.json.schema Artifact: vertique-json-schema Depends on: core API reference: javadoc.io

Framework-support schema generation for sibling Vertique modules. It produces deterministic, annotation-driven Draft 2020-12 JSON Schema documents from resolved Java Type values (Victools configured with the Jackson, Jakarta Validation, and Swagger 2 modules). It is transport-neutral and has no dependency on REST, MCP, Vert.x Web, vertx-json-schema, Dagger, Micrometer, or OpenTelemetry.

Applications do not use this artifact directly. Install vertique-rest-validation (RestValidationModule), vertique-rest-openapi-docs, or vertique-mcp-server instead. Every public type in dev.vertique.json.schema is an INTERNAL framework seam — outside the application maturity promise — even though this module’s Status is Stable for the generation behavior those consumers rely on.

This module is not a schema registry, a validation engine, or a general-purpose schema DSL. It produces one canonical document per call; framework consumers own caching, validator compilation, and runtime value validation.


Do not add this dependency from application code. Use it only when authoring or extending a Vertique framework module that must synthesize JSON Schema from Jackson / Jakarta Validation / Swagger annotations (today: rest-validation, rest-openapi-docs, mcp-server). Application projects that need request validation install RestValidationModule; projects that need published OpenAPI or MCP tool schemas install those modules.


AnnotationJsonSchemaGenerator is constructed through exactly one of three static factories, each selecting how Victools discovers Jackson properties and which profile-declared schema-type overrides apply:

  • withVictoolsDefaults() — Victools’ own default mapper, with no profile override applied. This is the mode a consumer without a resolved JsonMapperProfile uses.
  • forInputProfile(JsonMapperProfile) — property discovery and external property names use the input-direction Jackson introspection of profile.mapper(); mapper mix-ins, explicit names, naming strategies, and write-only/read-only access are honored, so read-only properties are not advertised as accepted input; only the profile’s INPUT- and BOTH-direction schema-type overrides apply.
  • forInputProfile(JsonMapperProfile, jakarta.validation.Validator) — identical to forInputProfile(JsonMapperProfile), except that value-schema constraints are additionally sourced from Bean Validation metadata — the annotation walk still runs first, as the floor every generation mode shares; the metadata source only supplements or, for a bounded set of shapes, corrects it. See “Constraint sources” below. Pass null (or use the single-argument overload) when no Validator is available; a null validator is exactly the single-argument overload’s behavior, not a degraded mode.
  • forOutputProfile(JsonMapperProfile) — property discovery and external property names use the output-direction Jackson introspection of profile.mapper(); the same mapper metadata applies, so write-only properties are not advertised as emitted output; only the profile’s OUTPUT- and BOTH-direction schema-type overrides apply. There is no validator-accepting overload for this direction: constraint sourcing applies to input generation only.

All three modes install the Jackson module, the Jakarta Validation module (NOT_NULLABLE_FIELD_IS_REQUIRED, INCLUDE_PATTERN_EXPRESSIONS), and the Swagger 2 module — unconditionally, including a validator-backed input generator. The Jakarta Validation module is the floor: whatever it renders for a scoped field or getter is rendered whether or not a Validator is supplied, so a document generated with a validator still renders every keyword a document generated without one would have. A supplied Validator only ever supplements that floor — see “Constraint sources” below — it never disables or replaces it. The one documented exception is several @Pattern constraints in the default group on one member (see “Rendering” below): the annotation walk cannot see more than one of them at all, so a supplied Validator there changes what is rendered rather than only adding to or correcting it.

Constraint sources: the floor, and the Bean Validation supplement

Section titled “Constraint sources: the floor, and the Bean Validation supplement”

The input direction’s value-schema constraints (minLength, maximum, pattern, required, …) always start from a floor that is present whether or not a Validator is supplied, and gain a supplement on top when one is:

  • The floor. A field or getter with a Victools member scope is described by Victools’ own Jackson and Jakarta Validation modules, unconditionally. A creator parameter, setter, or builder method has no such scope; its constraints are read directly from Jackson’s merged annotation map (which already carries the same-named field’s and getter’s annotations — Jackson’s own statement that they are one logical property, never a name coincidence the walk goes looking for), and a builder method borrows the built type’s Jackson-introspected property of that same wire name — also unconditionally, whether or not a validator is supplied — when the borrow is sound. The floor’s own translation renders the @Positive/@PositiveOrZero/@Negative/@NegativeOrZero family for every unscoped member — a creator parameter of either kind (read from Jackson’s merged annotation map, which already carries the same-named field’s annotations), a builder method (read from the method’s own annotations), and a setter through its backing field (a void setter method cannot itself carry a Bean Validation constraint, and an annotation on a setter’s or builder method’s parameter is not read) — and for a type-use constraint on a value type, matching what the scoped Jakarta module renders for a field or getter; @Email and Hibernate-only constraints stay untranslated by the floor. A family keyword never loosens a stricter @Min/@Max/@DecimalMin/@DecimalMax bound declared on the same member. Builder borrow assumption (owner ruling, spike/deserializer-driven-schema, round 2). A builder method is assumed to set the built property of that same wire name. The borrow now splits on whether that built-type property has a getter:

    • Getter-backed property: borrowed unconditionally, for any builder. This is exactly what main’s field walk always did — it published every getter-backed field’s constraints regardless of what any builder method’s body did with the value before assigning it — so the round-2 ruling restores that behavior rather than gate it on the builder’s own shape. A hand-written builder that transforms the value before assigning it (dividing cents into whole units, say) still has its getter-backed built field’s constraint borrowed, exactly as a genuine Lombok builder’s does.
    • Getter-less property (the private, no-accessor case): bounded to the shape BuilderBorrowDetector can vouch for. Here the borrow is guaranteed by construction only for a Lombok @Builder setter, which this framework’s own configuration types use throughout; a hand-written builder that does not reproduce that shape carries no such guarantee — the walk has no way to read a method body — so the borrow is bounded to the shape BuilderBorrowDetector detects entirely through java.lang.reflect over Jackson’s own RUNTIME-retained annotations: the built type carries @JsonDeserialize(builder = ...) naming a static nested class of the built type named <Type>Builder; that class carries @JsonPOJOBuilder(withPrefix = "", buildMethodName = "build") — the exact values @Jacksonized generates — and a zero-argument build() returning the built type; and, for the property in question, a builder method with exactly one parameter whose type and name both match the built field exactly. Detection deliberately does not read @lombok.Generated: that annotation carries RetentionPolicy.CLASS, so recognizing it would mean parsing the compiled class file directly (as JaCoCo’s own coverage exclusion does) rather than through ordinary reflection — disproportionate surface for a detection hint in a module whose compile dependencies are deliberately frozen. A hand-written builder that reproduces the whole shape by hand resolves too — the ruling tolerates that: it says a hand-written builder may go unresolved, not that it must; one that does not reproduce it gets no borrowed constraint at all, and its property is published by type only.

    The Bean Validation supplement’s join for a builder method follows this same two-branch rule (round 2 correction), evaluated against the same resolved built-type property the floor’s own borrow shares (see “The join” below): when the built type’s Jackson-introspected property for that wire name has a getter and a backing field, MetadataConstraintSource joins unconditionally, exactly as the floor does — so a constraint declared through an XML mapping, or on a member Jackson does not merge annotations for, still supplements a getter-backed builder property once a Validator is supplied, the same as it already does for a field or getter with a schema-library member scope. Only for the getter-less property does the supplement still consult BuilderBorrowDetector’s soundness check (MetadataConstraintSource reflects over the built class directly, so without this gate it would silently re-add a hand-written builder’s constraint the floor never publishes for that shape, whenever a Validator happens to be supplied) — an ordinary setter’s join is unaffected either way, since a setter’s own field is never in question the way a builder’s built-type field is. A creator parameter’s own equivalent — the compiled Java parameter name coinciding with an unrelated field’s name — is not this kind of assumption and is never joined on: a creator parameter joins a field only by wire name, which is Jackson’s own statement that the two are one logical property; nothing here reads a constructor body either, so nothing here is ever “guaranteed by construction” for a creator the way a Lombok builder’s setters are. One exception is exact rather than assumed: a record’s creator parameter joins its record component by position, but only for the canonical constructor (where component i is parameter i by language definition); any other creator — including a non-canonical @JsonCreator constructor with a different parameter order, and a record’s @JsonCreator static factory — joins by wire name like every other creator (a non-canonical creator parameter whose wire name matches no component name therefore joins nothing: it is neither published nor reserved, the same posture an ordinary class’s renamed creator parameter has). This is what keeps a @JsonCreator static-factory parameter’s own constraint from being dropped even under a validator: Bean Validation itself can join a creator parameter only through a constructor, but the floor reads the parameter’s own annotation directly and does not care which kind of creator it belongs to.

  • The Bean Validation supplement (Validator.getConstraintsForClass; consulted only when a Validator is supplied to forInputProfile, and only in addition to the floor above). Unlike the floor, it sees a constraint declared entirely through an XML mapping, a constraint on a member Jackson does not merge annotations for, and a List/array container-element constraint. A constructor-parameter constraint is not one of these: the floor’s own creator-parameter translation already reads it directly from the parameter’s own annotations, so the supplement’s equivalent join — by constructor and parameter index — only ever contributes an addition, where the floor’s own read left something unset. Every keyword it proposes is either an addition — merged onto the floor’s own rendering only where the floor left that keyword unset — or a correction, for the one named set of shapes the floor is known to render incorrectly or not at all (@Range, @Length, @URL, and a @Pattern flag — vertiquehq/vertique-dev#606): those replace the floor’s rendering for that keyword unconditionally. The supplement never removes a keyword the floor already rendered correctly, including one declared in a non-Default Bean Validation group — the floor has no notion of validation groups at all, so a @NotNull(groups = Admin.class) on a plain field still renders required, exactly as generation without a validator would; only what the supplement itself would add is filtered by group. A member the floor and the supplement both render the same constraint for is expected to agree — see SchemaCorpusMetadataCrossCheckTest’s byte-identical cross-check.

The join. A field- or getter/setter-backed property joins to a PropertyDescriptor by the member’s Java bean name — the field name, or the name a getter implies by stripping its get/is prefix, or a setter implies by stripping its set/with prefix — never by the wire name; a builder method joins the same way, on the built type, following the same two-branch rule as the floor’s own borrow (see “Builder borrow assumption” above): when the built type’s Jackson-introspected property for that wire name has a getter and a backing field, the supplement joins unconditionally; only for the getter-less property does it still require BuilderBorrowDetector to judge the join sound. For the getter-less shape this matches the floor exactly: an unresolved hand-written builder’s property contributes no supplement, exactly as it gets no borrow from the floor. For a getter-backed shape, the two sources both join, but they are not redundant: the floor’s own borrow only ever sees what the schema library’s own Jakarta Validation module can reach reflectively, so the supplement’s own addition/correction merge over that same property still adds a constraint declared through an XML mapping, or on a member Jackson does not merge annotations for, once a Validator is supplied, exactly as it already does for a field or getter with a schema-library member scope. The wire-name join is resolved once, by both sources together: InputPropertyDescriber looks up the built type’s Jackson-introspected property for the member’s wire name a single time and shares that same definition with the floor’s own borrow and the supplement, so the two can never disagree about which built property a setter or builder method means. That join still has one known false-positive shape: a built-type property that merely shares the builder method’s wire name but is actually assigned by a different member entirely is borrowed from anyway — the module reads Jackson’s own property identity, never a builder method’s body, so it cannot distinguish “this wire name happens to match” from “this builder method actually sets this field.” Setter-only field-borrow fallback (owner ruling, spike/deserializer-driven-schema). The floor itself joins a setter to its backing field through Jackson’s own BeanPropertyDefinition#getField() — the field Jackson associates with the same wire-named property, guaranteed by construction to be the one the setter’s value corresponds to. That accessor is null whenever Jackson’s property definition carries no field member at all — for example a private field with a setter and no getter and no public field, or a transient field regardless of whether a getter is also present; other mapper settings can produce the same absence. Bounded to exactly “no field member” (the getter’s own presence or absence is irrelevant — a transient field with both a getter and a setter still has no getField() to join through, so it falls back the same as the getter-less shape; a non-transient field with a getter still joins through getField() normally and never reaches this fallback), the floor falls back to the field whose Java name equals the setter’s own implied name — stripping only its own set/with prefix (impliedFieldName), narrower than the get/is/set/with convention named above, since this fallback only ever sees a setter, never a getter — which still carries the constraints the developer wrote for the value even though Jackson’s own property metadata cannot join it directly. A creator-parameter property joins to a ParameterDescriptor by its declaring constructor and parameter index (SettableBeanProperty.getCreatorIndex()), never by name; a static-factory creator’s parameters join to nothing in Bean Validation (constrained constructors only), which is exactly why the floor’s own annotation read — not the supplement — is what renders that shape. Where a property matches nothing in the supplement, it contributes no addition and no correction — a silent no-op, not a failure. A List/array value’s container-element constraints (getConstrainedContainerElementTypes(), type-argument index 0) merge onto the property’s items subschema when that subschema is inline, as an addition; a Map value’s element position is also a described position on the input direction (see “How a Map value is described”), but this supplement’s own container-element merge still targets a List/array’s items subschema only, unaffected by that change — it does not merge onto a Map’s own additionalProperties.

The value-position kind (minLength vs. minItems vs. minProperties for the same @Size shape) is derived from the member’s declared Java type, never from the schema’s own rendered type keyword — that keyword is unavailable at the point a Map, a bean, or an Optional value’s constraints are applied. Deriving it from the schema’s type instead was tried and reverted: it silently misrendered @Size on a Map as maxLength. The declared type is resolved as Jackson itself sees it for the built type’s own parameterization — never a raw java.lang.reflect.Field or Method’s reflected type, which erases a generic holder’s type variable to its bound regardless of what a particular embedding binds it to. A member whose resolved kind is neither STRING, ARRAY, nor MAP (a number, or an unrecognized kind) contributes no @Size/@NotEmpty/@NotBlank size keyword at all: none of those three keyword families applies to it, so the correct render is nothing, matching what the annotation walk renders for the same member kind.

The group filter. Only a constraint whose declared groups are empty or contain jakarta.validation.groups.Default is proposed by the supplement (as either an addition or a correction). @Valid cascades are never consulted, because the generator already descends into nested types on its own.

Rendering. @Size renders minLength/maxLength, minItems/maxItems, or minProperties/maxProperties depending on the value’s kind; @Min/@Max/@DecimalMin/ @DecimalMax (respecting inclusive), @Positive/@PositiveOrZero/@Negative/@NegativeOrZero; @NotNull/@NotBlank/@NotEmpty mark the property required — matching Victools’ NOT_NULLABLE_FIELD_IS_REQUIRED exactly, which treats all three identically; @NotBlank/ @NotEmpty additionally floor the size keyword at 1; @Email renders format: email; Hibernate’s @Length, @Range, and @URL render as corrections (recognized by fully-qualified annotation class name — never by simple name alone, so an application-defined constraint whose own simple name happens to collide with one of these, or with a plain Jakarta Validation type, is never mistaken for it — and never by importing hibernate-validator’s constraint classes, so the metadata source stays usable with any Jakarta Validation provider). A @Pattern’s flags are embedded as an inline Java regex modifier group ((?i:...), …; with COMMENTS, a line break precedes the closing parenthesis only when a trailing # comment would otherwise swallow it) as a correction, after the regexp is first compiled with its flags — one java.util.regex rejects fails generation with a diagnostic naming the property, never echoing the expression — measured against the real io.vertx.json.schema validator, which compiles the pattern keyword with plain java.util.regex.Pattern and honors this — except CANON_EQ, which has no embeddable modifier character and fails generation with a bounded diagnostic naming the property. A regexp that compiles with its flags but whose embedded group does not — an open \Q quote, or comments mode switched on inline, can cause this — fails generation with a diagnostic naming the property, never echoing the expression. Two or more @Pattern constraints in the default group on one member — Jakarta Validation’s own @Pattern.List repetition — render as an allOf of one single-pattern subschema per constraint, sorted for deterministic output, rather than the second silently overwriting the first; a composed constraint’s own leaves render the same way as if declared directly, recursively, in addition to — never instead of — the composing annotation’s own rendering when it is itself a recognized type (Hibernate’s @Range is itself composed of @Min + @Max with the same bounds, confirmed by disassembly). An unrecognized constraint type is skipped with a DEBUG-level System.Logger log naming the type and the property (this module carries no logging-facade dependency; see “Dependencies”). Without a Validator supplied, the annotation walk does not see a repeated @Pattern at all — the member’s actual reflective annotation is the @Pattern.List container, not a repeated @Pattern — so this shape is one of the few where a validator changes the rendered document rather than only supplementing it silently.

Correction timing. A correction is not applied at the moment Victools hands back a scoped member’s schema: measured, the library’s own Jakarta Validation module does not finish writing every attribute (pattern under INCLUDE_PATTERN_EXPRESSIONS in particular) by that point, and an immediate write there made the library’s own later write treat the correction as a conflicting value and wrap both into allOf instead of the correction ever winning. Corrections and additions for a scoped member are therefore recorded and applied once the whole document is finished generating, before nullability and alias expansion run — the same “must wait for the finished document” technique this module already uses for nullability and alias expansion.

A correction never loosens a stricter bound the floor rendered from a different annotation. A correction is keyed by annotation type but applied by keyword: @Range(min = 10, max = 20) beside a separate @Min(15) on the same member would otherwise let the @Range correction overwrite the floor’s own minimum: 15 with minimum: 10 — looser than both the floor and the binder, since Bean Validation enforces the conjunction of every constraint on a member, not only the last one rendered. When both the floor and a correction set the same bound keyword (minimum/maximum/ exclusiveMinimum/exclusiveMaximum/minLength/maxLength/minItems/maxItems/minProperties/ maxProperties), the stricter of the two wins — the larger value for a “min” keyword, the smaller for a “max” one. For pattern, where “stricter” has no total order, a correction whose value genuinely differs from the floor’s is combined with it as an allOf of two single-pattern subschemas instead of replacing it, so a client must satisfy both. The one exception is #606’s own flagged-@Pattern rendering: when the correction’s pattern is exactly the floor’s own regexp wrapped in an inline modifier group, it is the same @Pattern annotation rendered twice at different fidelity, not two annotations in conflict, and the flag-aware rendering still replaces the floor’s outright.

@Schema metadata on a creator parameter or setter follows the same rule. For that unscoped member shape, pattern, minLength, maxLength, minimum/exclusiveMinimum, and maximum/exclusiveMaximum from @Schema are applied through this same correction path, so a differing @Schema(pattern = ...) composes as an allOf with a constraint-source @Pattern rather than dropping it, and a @Schema bound never loosens a stricter constraint-source bound the floor already wrote; @Schema’s description, title, format, enum, and nullable keep the pre-existing unconditional-overwrite behavior. A @Schema bound of the other exclusivity than the constraint source’s own — exclusiveMinimum beside a floor minimum, or exclusiveMaximum beside a floor maximum (and symmetrically) — writes a different keyword, so it is never compared against or dropped for the floor’s bound; both are kept, published together as a conjunction the binder must satisfy. An any-setter map’s @Schema(minProperties/maxProperties) and a @Size-derived bound on the same map follow the identical stricter-wins rule.

Bootstrapping a Validator. HibernateValidator.configure().messageInterpolator(new ParameterMessageInterpolator()) avoids an expression-language dependency; the default message interpolator does not. This module never constructs a Validator itself (Validation.byDefaultProvider() is never called here) — the caller always supplies one.

Which properties the input direction describes

Section titled “Which properties the input direction describes”

forInputProfile(JsonMapperProfile) describes a walked property when Jackson reports it deserializable or it has a backing field, and its access is not READ_ONLY. A backing field counts because Jackson populates a private field through reflection wherever the mapper infers property mutators — Jackson’s default, and the setting every built-in profile leaves alone — so the commonest DTO shape of all, a private field reachable only through a getter, is described with its type and format. So are a field-backed getter-only List<String> or Map<String, String> and a type holding such a shape as a property.

A builder type is filled through its builder rather than through the field. A builder method’s own constraint is borrowed from the built type’s Jackson-introspected property of the same wire name — never a raw field-name scan. When that property has a getter, the borrow is unconditional, for any builder — a Lombok @Builder @Jacksonized type, a hand-written builder that reproduces its shape, or a hand-written builder that does not (round 2, owner ruling). When the property has no getter, the borrow stays bounded to the shape the builder borrow detector can vouch for: a Lombok @Builder @Jacksonized type, or the exact shape it generates by construction. Closed (BG1): a constrained private field with no @Getter at all is invisible to Jackson’s own introspection, so the floor has nothing to join the builder method to — that one property still publishes, by type only, with no borrowed constraint; it is not the whole document that stays unconstrained. When a Validator supplement is active, it renders the constraint for that property anyway, by its own field-name join, independent of whether the floor could borrow it.

The backing storage of a @JsonAnySetter or @JsonAnyGetter is never described as a named property, because the keys those accessors collect are extra keys rather than members of the object’s property set. The storage is identified by member alone — a field annotated @JsonAnySetter, the record component whose field that is, a field annotated @JsonAnyGetter, and the field a method @JsonAnyGetter returns — so a real property is never hidden merely because its name matches one an accessor method implies: a constrained attribute property beside an any-setter setAttribute(String, Object) stays described with its constraint. For a type Jackson deserializes as map-like or collection-like, the any-setter is ignored, as Jackson itself ignores it.

A property marked @JsonIgnore or read-only stays absent from the input document, and a write-only property is described with writeOnly: true. This rule applies to the input direction only: forOutputProfile(JsonMapperProfile) describes a property Jackson reports serializable whose access is not WRITE_ONLY, unchanged.

This rule decides which walked properties are described; it does not make every key the binder accepts a described property. A key the schema does not describe is left to the binder and to Bean Validation.

A type whose resolved deserializer is not a bean deserializer is refused with a bounded diagnostic when the type’s own class carries an explicit type-level @JsonDeserialize(using = ...) (or equivalent) — genuinely bean-like structure whose deserializer was swapped out — or when the mapper’s own reflective introspection still reports a settable property (a field, a setter, or a creator parameter) for the type even though its deserializer was attached some other way: a profile module’s SimpleModule.addDeserializer(...), or a BeanDeserializerModifier wrapper, carries no class-level annotation to detect, so an application DTO shaped this way is still bean-like and still has a field walk this refusal protects. A type with no settable property at all — a scalar, container, node, or Vert.x-style opaque wrapper such as JsonObject, JsonArray, or Buffer, none of which ever had bean properties to begin with — is instead described as accepting any JSON value, exactly like Object.class/JsonNode.class, both at the root and nested as a member; there is no field walk such a refusal could be protecting there. The io.vertx.* package is excluded from the settable-property check outright: a no-argument getter such as Buffer#getBytes() or a mutable-collection getter such as JsonArray#getList() makes plain reflective introspection report a property for these well-known wrapper types even though neither is ever bound as a bean.

A DelegatingDeserializer wrapper is unwrapped before this decision, bounded to a pure forwarder (W2 owner ruling; W-1, round 5 review finding, spike/deserializer-driven-schema). A mapper-wide BeanDeserializerModifier that wraps every bean deserializer in a forwarding DelegatingDeserializer subclass is a legitimate, if unusual, module shape — but the generator does not simply trust that the wrapper forwards; it checks. Unwrapping proceeds through getDelegatee() only while the wrapper’s own class overrides none of deserialize(JsonParser, DeserializationContext), deserialize(JsonParser, DeserializationContext, Object), or deserializeWithType(...) itself — i.e. DelegatingDeserializer remains the declaring class of all three, so every operation is guaranteed to reach the original bean deserializer unchanged. For that bounded shape the generator unwraps the chain down to its ultimate delegate, mirroring the existing TypeWrappedDeserializer unwrap, and describes the wrapped bean from the delegate rather than refusing it as an opaque type-level override; the same bound applies at all three seams the generator unwraps a DelegatingDeserializer at — the root/nested-reference seat, the member-level case-insensitive inline path (S-1, round 5 review finding), and the @JsonUnwrapped unwrapped-child loop (C-1, round 6 finding): an unwrapped child’s own root deserializer is unwrapped the same bounded way before the loop asks Jackson for its unwrapping deserializer, so a mapper-wide forwarding wrapper no longer leaves the child’s own bean deserializer hidden behind it. A DelegatingDeserializer subclass that overrides any of the three methods — accepting a wire form of its own before, or instead of, ever reaching the delegate — is not unwrapped: it is treated as a replaced deserializer and refused exactly like the shape described above, with the same diagnostic, since unwrapping straight to the delegate’s plain bean description would silently drop whatever the override actually does. This is deliberately narrower than the refusal it exempts from: a third-party type with a settable property whose deserializer a profile module attaches directly (SimpleModule.addDeserializer(...), or a BeanDeserializerModifier that returns something other than a pure-forwarding DelegatingDeserializer wrapping the original bean deserializer) is not a DelegatingDeserializer chain to unwrap, and stays refused exactly as described above — the remedy for that shape is still a JsonSchemaTypeOverride. An @JsonUnwrapped child’s own unwrappingDeserializer(...) result decides its fate, matching Jackson’s own bound (C-1, round 7 finding). When that call returns the very same deserializer instance back, that is Jackson’s own documented signal that the deserializer does not support unwrapping at all — MapDeserializer, the abstract-type-id-resolving deserializer, the untyped-Object/JsonNode deserializers, OptionalDeserializer, and others all take this path — and BeanDeserializerBase.resolve() then leaves the member bound as an ordinary nested property, which the describer’s own first (non-unwrapped) property loop already publishes under the member’s own name; the unwrapped-child loop leaves it there rather than refusing it. Only when the call returns a different instance that does not resolve to a bean deserializer — a genuine custom deserializer changing the wire shape, not Jackson declining to unwrap — is the member refused, with the same bounded custom-deserializer diagnostic the root seam throws, naming the unwrapped member alongside its type so a parent with several unwrapped members can tell which one was refused. This bound is deliberately narrower than the root seam’s own refusal shape immediately above: the root seam separately describes a map-like type, returns nothing for a polymorphic base (left to the Jackson module’s own subtype resolver), and describes an opaque foreign type as unconstrained — none of which the unwrapped-child loop repeats, since the parent’s own first property loop already publishes any of those shapes left un-unwrapped as an ordinary nested property, and repeating them here would duplicate rather than protect that path. A prior round 6 fix (C-1) refused every such child outright regardless of which of the two instances unwrappingDeserializer(...) returned, so generation failed with a diagnostic for every DTO with an unwrapped Map, abstract @JsonTypeInfo base, Object, JsonNode, or Optional member; this is the corrected bound.

A case-insensitively bound @JsonUnwrapped child is refused, at every seam a wrapper can hide it behind. requireCaseSensitive runs immediately once an unwrapped child resolves to a bean deserializer, so the C-1 unwrap fix above also restores this refusal under a mapper-wide forwarding wrapper: before the fix, the wrapper left the child’s root deserializer un-unwrapped, the loop’s own instanceof BeanDeserializerBase check failed first, and generation silently succeeded with the child’s property simply missing instead of refusing the genuinely case-insensitive binding.

A delegating @JsonCreator — object-delegating (Mode.DELEGATING over a single non-array-like parameter) or array-delegating (the same mode over a List/array-shaped parameter) — is refused with a bounded diagnostic, in either shape: the whole value is bound through the delegate type, so no named property of the creator’s own type is ever read from the wire, and a document describing the delegate type’s shape honestly would open the boundary to keys the binder never accepted through a named property and leave any constraint on the creator’s own type’s fields dead on input. Declare a JsonSchemaTypeOverride for the type on the profile, or bind it through a property-based creator.

On the input direction only, a Map<K,V> position — a named property, a creator parameter, an any-setter’s own extras value, a collection item, or a nested map — describes V’s own schema as its additionalProperties, through the same shared value-position renderer every other described value position uses: V’s own type (including a profile override on V, applied first), V’s own declared constraints, and a type-use constraint declared on V itself — Map<String, @Size(max = 3) String> publishes additionalProperties: {"type":"string","maxLength":3}. A Map subclass (class Tags extends HashMap<String, @Size(max = 3) String>) is described the same way, with the type-use overlay source read from the subclass’s own supertype chain (Class.getAnnotatedSuperclass()/getAnnotatedInterfaces()) rather than a member position, since a type-level reach carries no member of its own — a further subclass declaring no type argument of its own still resolves to the nearest ancestor that does. V of Object, JsonNode, TreeNode, or another entry of the generator’s own unconstrained-value-type list stays open, exactly like that same type at a named position. A Map-valued Optional<T> entry is described as T’s own schema, marked nullable, through the same Optional-null-and-constraint rendering a named Optional<T> member already gets; a null inside a non-Optional map value is rejected as wrong-typed once the value’s own schema is described — a tightening relative to earlier behavior, where nothing was ever rejected there because nothing was described there.

Because V’s schema is now built through the same override/refusal chain a named position already uses, every refusal already reachable at a named position — a custom or module deserializer, a delegating creator, a case-insensitive binding combined with an alias, nested @JsonUnwrapped on an any-setter-carrying type — becomes reachable through a map value’s own value type too: router or tool schema construction now refuses at startup for an affected DTO where it previously published an undescribed {"type":"object"} and started. Declare a JsonSchemaTypeOverride for the offending value type to admit it.

An explicit @Schema(additionalProperties = TRUE|FALSE) on a Map-typed member or getter stays ignored, exactly as at every earlier version — a map’s entries are its own content, so there is nothing “additional” to forbid, and the renderer writes V’s own schema into additionalProperties regardless of the annotation’s value. The only behavior this rule adds: when the annotation is present on a Map-typed member or getter, generation logs one WARNING-level line, through this module’s own existing System.Logger facility (System.getLogger(...), named after the emitting class — this module carries no logging-facade dependency of its own; see “Dependencies”), naming the member and stating the annotation has no effect on the generated input schema. An application routes that log line into SLF4J the same way it would any other JDK System.Logger output: by adding org.slf4j:slf4j-jdk-platform-logging to its own runtime classpath. Generation never fails over this annotation, and a class-level @Schema(additionalProperties = FALSE) on a bean value type (an any-setter’s own closure rule, below) is unaffected — this rule is scoped to a Map-typed member or getter specifically.

The output-direction generator, and Option.MAP_VALUES_AS_ADDITIONAL_PROPERTIES’s own per-builder default, are unaffected by any of the above: this renderer is reachable only from forInputProfile’s own provider chain.

How an any-setter’s extra keys are described

Section titled “How an any-setter’s extra keys are described”

A type with a @JsonAnySetter describes its extra keys through additionalProperties, typed by the any-setter’s value type: the map value type of a field-level any-setter, or the second parameter of a method-level one. A field’s map value type is the content type Jackson resolves for the field, so a map subclass declares it correctly however its own type parameters are written — a StringKeys<Integer> over LinkedHashMap<String, V>, a Reversed<Integer, String> over LinkedHashMap<K, V>, and a non-generic IntMap extends LinkedHashMap<String, Integer> all describe integer extras — and a generic value type such as List<Integer> keeps its arguments. The value type is published as the generator’s own definition of that type, so a profile override, a format, and a shared definition apply to an extra value exactly as they do to a named property — a Map<String, LocalDate> any-setter’s extras carry format: date, and under vertique-strict a Map<String, BigDecimal> any-setter’s extras carry that profile’s decimal fragment.

An any-setter extras value (and a map value — see “How a Map value is described”) declared as Optional<T> is described as T’s own schema, constraints included, and admits an explicit null, matching Jackson’s own Optional.empty() binding.

When an @JsonUnwrapped parent and one or more unwrapped children each declare an any-setter, Jackson feeds a shared extra key into every one of them, and the key’s additionalProperties describes the conjunction of every any-setter’s own value schema — composed as an allOf of each any-setter’s own value description (a type-use constraint on a value included) and folded by the generator into one schema where the parts are plain, so a value violating any one any-setter’s own constraint is rejected. A bean-valued part is described inline rather than by reference, unless its value type carries a profile override, which is kept as a reference; a self-referential bean-valued part fails generation with a bounded diagnostic naming the type and the JsonSchemaTypeOverride remedy rather than being described from the library’s own reflection. Where several such any-setters bound their own map with @Size or @Schema(minProperties/maxProperties), the stricter bound wins across the conjunction. A key that is a named property of a child is described by that named property alone.

A member-level @Schema(additionalProperties = FALSE) on a property whose value type declares its own any-setter, directly or through an unwrapped member of its own, closes that one member’s own extras by describing the member inline — reusing the case-insensitive inline path’s own override-first, requireNotDelegating, and scalar-creator machinery — while the same value type referenced elsewhere, without the annotation, keeps its own shared, open definition. A member that is both case-insensitive-inline and FALSE composes as one inline description, case-insensitive handling kept. A member whose value type carries a profile override cannot honor FALSE from the member: generation refuses with a bounded diagnostic naming the member and the remedy (declare the closure inside the override fragment instead). A self-referential member reached through the inline description, or through a plain reference to a type still being described inline, refuses the same way rather than overflowing the stack or falling back to a standard, reflection-built definition. A member-level TRUE, or the annotation’s absence, never loosens a class-level FALSE already closing the value type.

An unconstrained value type — Object, JsonNode, TreeNode, or a wildcard or raw form resolving to one — is described as the empty schema {}, which accepts every JSON value. A class-level @Schema(additionalProperties = FALSE), declared or inherited, keeps the object closed and is never overridden. A class-level @Schema(additionalProperties = TRUE) says only that extras are allowed, which the typed description already says more precisely, so the description wins. A type Jackson deserializes as map-like or collection-like is described exactly as if it declared no any-setter: Jackson never routes a key to that any-setter, so describing it would reject legal map entries.

Where extra keys are described on a case-sensitively bound type, the document also carries

"propertyNames": {"not": {"enum": ["id", "role"]}}

which lists one reserved set, computed as a difference rather than as a list of categories: every name Jackson binds on input for the type, minus every name the document publishes under properties, minus every name whose Jackson property definition carries no member at all. Without it, a name the document never published would be accepted as an ordinary extra key and bound straight into the member it names. Its members are therefore a consequence of the rule rather than separate cases:

  • a name marked @JsonIgnore, and a name a class-level @JsonIgnoreProperties ignores;
  • a name whose access is read-only, or that is otherwise invisible on input;
  • the storage field a method @JsonAnyGetter returns, which Jackson fills through that getter;
  • a name bound only through a setter with no field, through an accessor pair over a differently named field, through a @Schema(hidden = true) field, or through a transient field;
  • a @JsonAlias spelling more than one property claims, and a spelling of a property the document does not publish under its own wire name — neither is published, so neither is subtracted;
  • a @JsonUnwrapped child’s own alias spelling, and its own hidden, ignored, or bound-but-unpublished name, keyed by the child’s wire name with any unwrapped prefix or suffix applied — folded in beside the parent’s own names, scoped to when the parent actually has an any-setter (the only case where an unrecognized key binds anywhere at all). A nested @JsonUnwrapped chain under an any-setter type — where this one-level fold cannot itself be sound — is refused with a bounded diagnostic instead.

A field carrying both @JsonAnyGetter and @JsonAnySetter reserves no storage name, because Jackson stores a key named after it as an ordinary entry of the map. The published-name subtraction is by member and never by spelling, so a property the document publishes under some other name is not reserved. The memberless subtraction fails open for the one shape whose member identity cannot be recovered — a @JsonCreator parameter renamed away from the field it populates — which therefore keeps accepting the traffic it already accepted; constrain that shape with Bean Validation. A declared propertyNames in the document comes only from a profile override fragment, which replaces the whole description of its class: it is published exactly as declared, and is never guarded or listed — the generator itself never writes a propertyNames alongside one already on the node.

The redaction manifest. The propertyNames rule above, and the case-insensitive reserved-name entry described below, are guards AnnotationJsonSchemaGenerator marks as it emits them. describe(Type) returns, beside the canonical document, a RedactionManifest whose sorted RFC 6901 pointers name every such guard in the finished document — including every copy alias expansion or folded-copy resolution makes of one — so removing exactly those locations leaves no reserved name in any spelling or fold, while every other assertion, including the non-ASCII refusal below, remains. A propertyNames a profile override fragment declares carries no guard and is never listed. The mark is a generator-private keyword, x-vertique-reserved-name-guard, removed before the document is canonicalized, so no generated document carries it. A profile override fragment that carries it as a member of a schema object, at any depth, is refused when the generator is constructed — as one carrying the alias-plan keyword is (see “How an alias spelling is described”) — because the manifest would otherwise list an assertion the generator never emitted; the refusal names the keyword and the profile. See “Canonical output” below.

Case-insensitive binding and Unicode code folding. A type bound case-insensitively (mapper-wide, class-level, or member-level @JsonFormat) is described with patternProperties — one ASCII case-folding pattern per bound name that has an ASCII letter, since Jackson’s own case-insensitive lookup measurably uses String#toLowerCase()/toUpperCase() with no explicit Locale, which a fold pinned to any one locale could silently drift from. The key matches every other ASCII casing of the name and never the exact spelling, which properties validates, so a value is checked once whatever the casing of its key: a negative lookahead excludes the exact name, then the fold follows (name gives ^(?!name(?![\s\S]))[nN][aA][mM]eE; a.b gives ^(?!a\.b(?![\s\S]))[aA]\.bB). The entry’s value is a copy of that member’s own resolved schema, whatever its type, so a differently cased key carries the same constraints as the exact spelling — including a member described through a definition reference (a nested DTO, a map, an enum, a polymorphic base, or a list, array, or Optional of those). A name with no ASCII letter has no other spelling and gets no entry; a name Jackson folds beyond ASCII is still refused when the schema is generated. The copy is made by the generator and its private placeholder keyword, x-vertique-folded-copy, never appears in a generated document; a profile override fragment that carries it as a member of a schema object is refused when the generator is constructed, like the guard keyword above. A body spelling such a member in a non-exact casing with a value its schema rejects is now rejected, as the exact spelling already is (before, that copy was an unresolved, near-empty schema and the value was accepted); verdicts for the exact spelling are unchanged. The published input schema text of every case-insensitively bound type differs from earlier releases. The fold is anchored with (?![\s\S]), not $: io.vertx.json.schema compiles the pattern keyword with plain java.util.regex.Pattern, whose $ — without Pattern.MULTILINE — still matches immediately before a single trailing line terminator, not only at the true end of input; a key ending in a newline would otherwise wrongly match the fold. (?![\s\S]) is the ECMA-262 end-of-input form — a negative lookahead asserting that no character follows — and under java.util.regex it matches exactly what \z matches, with no exception for a trailing line terminator. Every case-insensitively bound type additionally carries a propertyNames rule refusing any key containing a non-ASCII code unit, unconditionally — where extras are also described, paired with a second, separate refusal of the reserved names’ ASCII case folds as the two entries of one allOf (the non-ASCII refusal first); where they are not, on its own. Refusing a key matching either pattern is exactly refusing a key matching their alternation, so the separated shape accepts the same keys a single combined pattern would; only the reserved-name entry (propertyNames/allOf/1) is a guard the redaction manifest lists, so removing it leaves the non-ASCII refusal in place. This closes a real gap: a non-ASCII code point can fold to an ASCII letter under Java’s locale-independent Unicode case mapping regardless of locale (U+212A KELVIN SIGN folds to ASCII k), so a key spelled with it binds at the binder to the same member an ASCII spelling would. Where extras are described, the ASCII-only patternProperties fold and the reserved-name pattern both miss it at the schema, so without this rule the key would fall through to additionalProperties and validate as a permissive extra instead of against the real member’s own constraint. A closed type (no any-setter) at the REST gate has no other closure at all — REST has no hardener, and a closed type publishes no additionalProperties — so without this rule the key would simply be accepted and bound; the rule is emitted unconditionally for exactly this reason, not only where extras are described. A closed type still relies on Bean Validation, when one is supplied, as its own backstop after binding.

Member-level case-insensitive binding. A member bound case-insensitively only through its own contextual @JsonFormat(with = ACCEPT_CASE_INSENSITIVE_PROPERTIES) is described inline at that position rather than by reference to the type’s ordinary (case-sensitive) shared definition, since the same class used elsewhere without the annotation stays case-sensitive there. Before describing it inline, the same delegating/array-delegating refusal the type would face at any other position still runs, and a profile override declared for the member’s type still applies — resolved through the ordinary reference lookup, which re-enters the full provider chain — in preference to the inline description. A class-level @Schema(additionalProperties = FALSE) on the value type, declared or inherited, is preserved on that inline description exactly as on a shared definition — the hand-built node does not drop the closure.

Every @JsonAlias spelling of a visible input property that does not back an any-accessor is listed under properties with a copy of the schema that property is published with, so the same constraints apply under either spelling. Without it an alias reaches a gate undescribed: {"qty": 999} binds a property declared @Max(10), and an unknown enum value under its alias binds the default constant.

"properties": {
"quantity": {"maximum": 10, "type": "integer"},
"qty": {"maximum": 10, "type": "integer"}
}

A spelling is listed exactly where its property’s own wire name is published, because listing copies that entry: a spelling of a property the document never publishes — a @Schema(hidden = true) field, an accessor pair with no same-named field, a property published under some other name — is listed nowhere and stays a reserved name. A spelling that is already another property’s name is not listed either — whether or not the document publishes that property, because the type still binds that name — and neither is a spelling more than one property of the type claims: the generator cannot predict which claimant Jackson binds such a key to, so publishing it would attach one claimant’s schema to another claimant’s value. A contested spelling is therefore published nowhere, named in no rule, and reserved where extras are described; constrain that shape with Bean Validation.

A required aliased property leaves the top-level required list, because one rule per aliased property states which spellings may appear instead. Which rule depends on the selected profile’s own mapper, and on nothing else: a profile whose mapper enables JsonParser.Feature.STRICT_DUPLICATE_DETECTION — of the built-ins, vertique-strict alone — gets the strict form.

Profile Required property Optional property
Lenient (system, vertique) at least one spelling: anyOf of {"required": [spelling]} no rule
Strict (vertique-strict) exactly one spelling: oneOf of the same branches at most one: the same branches plus {"not": {"anyOf": [...]}}
"oneOf": [
{"required": ["quantity"]},
{"required": ["qty"]},
{"not": {"anyOf": [{"required": ["quantity"]}, {"required": ["qty"]}]}}
]

The rules are appended to one allOf, or stand alone when there is one rule and its keyword is free, and their branches carry only required or not, never properties, so a consumer that closes an object carrying properties does not close a branch. The rule is the schema’s alone: a Jackson binder accepts several spellings of one property under every profile, so the strict form is stricter than the binder rather than a description of it.

Listing runs over the finished document, after generation, so every reference a copied schema carries is already resolved. The plan is carried in the document under one generator-private keyword, x-vertique-alias-plan, which that pass removes; a type publishing a property under that exact wire name — including one listing would publish under it, so an alias spelling equal to the keyword is refused exactly as a property named for it is — is therefore refused at generation with a bounded diagnostic naming the type and the name, rather than being published stripped of its constraints. Rename such a property, or such a spelling, on the wire — for example with @JsonProperty or @JsonAlias.

A profile override fragment that carries the keyword as a member of a schema object, at any depth, is refused when the generator is constructed for a direction the fragment applies to: expansion would otherwise strip it without a trace, or execute it as a plan against the enclosing schema. Literal data is exempt. The listing pass and the refusal never enter the value of const, enum, default, examples, or example, so a fragment {"const": {"x-vertique-alias-plan": "mandatory", "value": "ok"}} is published exactly as written and still accepts only that object. A property whose own name is one of those keywords is a schema like any other and is still expanded.

How an unconsolidated allOf is folded (input direction)

Section titled “How an unconsolidated allOf is folded (input direction)”

Victools sometimes leaves an object allOf unconsolidated — most visibly for a polymorphic subtype whose discriminator is also a declared property (the base part describes it with type: "string", the discriminator part with const: "<name>"), and for a @JsonUnwrapped child whose definition provider leaves a single-part allOf beside the holder’s own already-flattened properties. After every other post-generation pass, and only for a generator built through forInputProfile(...), this module folds every plain part with an eligible target into one flat properties set, equivalent under the refusal conditions listed below, per Draft 2020-12 conjunction semantics: a property both parts declare becomes {"allOf": [...]} of the two branches (or is left as one copy when they are already equal), a property only one part declares is added directly when the receiving schema constrains no key it does not name (or is conjoined with its additionalProperties schema, when it has one), required becomes the union of both parts’, the receiving schema’s title/description wins when it has one (otherwise the folded part’s is copied), and a folded part’s own type: "object" is copied onto the receiving schema when it declares no type of its own. The fold is refused — leaving the allOf exactly as generated — whenever the receiving schema’s additionalProperties: false, patternProperties, unevaluatedProperties, or propertyNames could make adding an unnamed key unsound; whenever the receiving schema already declares a type other than the literal "object" and the folded part requires object-only; or whenever a local $ref elsewhere in the document points into the region the fold would rewrite. The result is that a subtype whose own schema is inline — not a $defs reference — and whose discriminator is also a declared property now publishes one flat property set with the discriminator present once, rather than split across sibling allOf parts — the shape an MCP-style hardener that closes a non-root object by provenance (additionalProperties: false on any object carrying non-empty properties, no $ref, no declared additionalProperties of its own) would otherwise close one part at a time, turning a property published only on a sibling part into a rejected “additional property”. A subtype whose own schema is a $defs reference — because the subtype is also used directly as a field — keeps its discriminator part unfolded beside that $ref sibling instead: the $ref part is never itself a fold target, so the discriminator part has nothing eligible to fold into and is left for such a hardener to close on its own. The output direction is unaffected: this is a normalization of the input direction’s own published document, wherever the fold applies. See AllOfFold’s class Javadoc for the full argument.

generateCanonical(Type) returns a fresh, compact JSON document with every object member whose key is default and whose value is exactly the string ##default removed, and every object’s keys recursively ordered by String.compareTo UTF-16 code-unit order; arrays are never reordered. The document is emitted through a generator-owned neutral writer, never through the profile mapper, so the valid-document guarantee does not depend on the mapper’s own serialization configuration. Equal resolved types, annotations, construction mode, mapper configuration, selected profile direction, and canonical override fragments produce byte-identical documents across independent instances and repeated calls. Calls on one instance are safe from multiple threads; the complete generation and canonicalization operation is serialized per instance.

describe(Type) runs the identical generation and returns a CanonicalSchema pairing that exact text, as json(), with a RedactionManifest: the sorted RFC 6901 pointers of every reserved-name guard in the finished document (see “Reserved names beside described extras” above), and a digest — "sha-256:" plus the lowercase hexadecimal SHA-256 of the UTF-8 bytes of the canonical form of the parsed document. matches(text) recomputes that digest for text with a strict reader — one that refuses content after the first JSON value, while still accepting trailing whitespace, and refuses an object repeating a key — and compares it with the manifest’s own, so it holds for json() however that text is later re-rendered (reordered keys, inserted whitespace), and fails for any difference that survives parsing and canonicalization. A difference parsing itself erases — an escaped spelling of a character, or another notation of a number that parses to the same value — does not change the result. It returns false for unparsable or malformed input and never throws for a content reason. generateCanonical(Type) is exactly describe(type).json(); an output-direction generator’s manifest, and a withVictoolsDefaults() generator’s, is always empty, since neither emits a guard.

Any failure — an unrepresentable Type, an invalid or conflicting profile override declaration, a detected structural conflict, or an unexpected Victools failure — is normalized to JsonSchemaGenerationException, a single bounded exception type with no Victools type in its signature. Stack exhaustion during generation, which is how a pathologically deep type graph fails inside the generator’s recursive descent, is normalized the same way, so a deep type does not bypass the bounded failure contract merely because the JVM reports it as an Error. A VM-level error such as OutOfMemoryError is deliberately not normalized: it describes the runtime rather than the requested type, and propagates unchanged.

A failed call leaves the generator fully reusable: it restores the per-generation state the underlying generator holds before propagating, so a rejected type never changes what a later call on the same instance publishes. Reuse a generator freely after a failure — there is no need to discard and rebuild one.

A profile’s jsonSchemaTypeOverrides() (declared in dev.vertique.core.json.JsonMapperProfile, dev.vertique:vertique-core) lets a profile describe an exact-class wire shape that Jackson inspection cannot infer — for example a BigDecimal field a matched custom serializer/deserializer pair represents as a bounded decimal string rather than a JSON number. The generator applies a profile’s override fragment as the baseline wire contract for that exact class wherever it is reached — the mapped root type, a property, or a collection element — and never to a map key. Property-level Swagger schema metadata and applicable Jakarta constraints then narrow that baseline through explicit conjunction; neither contributor overwrites the other’s declared keyword.

An INPUT override for a bean-like class must declare properties or additionalProperties. This is the documented remedy for a generator refusal (the custom-deserializer, delegating-creator, and case-insensitive refusals above), so nothing else validates that a declared fragment actually describes or closes anything. A bare {"type":"object"} — the natural one-line remedy — publishes as a non-root object with no properties and no additionalProperties, which the MCP hardener deliberately leaves open (it only closes an object that already declares a non-empty properties), and as an unconstrained body member at REST. Construction fails with a bounded diagnostic instead. “Bean-like” is decided the same way the custom-deserializer refusal decides it (a settable property by the mapper’s own reflective introspection, with the same java./javax./jakarta./ com.fasterxml.jackson./io.vertx. package exclusions), and a fragment that fully constrains its own wire shape some other way is exempt — it carries no open-object risk this rule exists to catch even though it declares neither properties nor additionalProperties. Exempt shapes: a type that wholesale replaces the object shape — a single non-object string, or an array not containing "object"; an enum; a const; and a oneOf/anyOf/allOf whose every branch itself qualifies by this same rule, recursively. A bare {"type":"object"} satisfies none of these and still fails construction. OUTPUT-direction overrides are unaffected.

A constraint that does not apply to the substituted wire type is not published as if it did. The numeric-domain keywords minimum, maximum, exclusiveMinimum, exclusiveMaximum, and multipleOf are suppressed at a property whose effective declared type excludes both number and integer — so @DecimalMin("0.01") or @Schema(multipleOf = 0.01) on a BigDecimal the profile republishes as a decimal string emits no numeric keyword against that string schema. Which contributor supplied the keyword is irrelevant; the effective wire type alone decides. Bean Validation still enforces the constraint against the materialized Java value — only the published, wire-facing keyword is dropped.

Suppression is per-property and never rewrites a shared $ref target, because several properties may reference the same generated definition and one property’s effective type is not the others’ to narrow. A shared definition is cleaned only on its own terms — when the definition itself declares a type that excludes both numeric types. So when an override fragment contributes a numeric keyword without declaring a type, that keyword stays visible at a property whose effective type is not numeric. It is inert there rather than wrong: JSON Schema applies a numeric keyword only to a number instance. Declare a type in an override fragment that carries numeric bounds if you want those bounds confined to numeric referrers.

generateCanonical(Type) accepts a resolved type, recursively: a non-null Class (including a primitive class, an array class, and a raw generic class), a ParameterizedType whose optional owner type, raw type, and arguments are themselves accepted, and a GenericArrayType whose component type is accepted. It rejects null, a TypeVariable, a WildcardType, any nested occurrence of either unresolved form, and an unknown custom Type implementation. The rejection is eager — it happens before Victools is invoked — so an unrepresentable type never produces a partially built document. A recursive object graph is fully supported; it is not an unresolved type.

Three annotation combinations fail generation rather than producing a schema that quietly misdescribes the wire:

  • An implementation = ... redirect on a property whose declared type graph carries a profile override. The Swagger module redirects the property’s resolved type before the profile’s override is consulted, so the override fragment would be dropped without a trace. Generation therefore fails with a bounded JsonSchemaGenerationException naming the property. Declare the wire shape through the profile override or through implementation, not both. Every form the Swagger module reads the redirect from is covered: a direct @Schema(implementation = ...), @ArraySchema(schema = @Schema(implementation = ...)) on a container’s element, and @ArraySchema(arraySchema = @Schema(implementation = ...)). An annotation on either the field or its accessor counts, matching how the Swagger module resolves it. The detection reads the property’s declared type — resolved against its declaring context, so a member inherited from a generic supertype is checked against the binding subtype’s actual class — plus, recursively, its type arguments and array element types, including the element or payload type a subclass or implementor binds in its extends/implements clause rather than declaring itself; a map key position is excluded, since a map key is never fragment-bearing. This search is deliberately wider than the set of positions an override fragment is actually published at: it rejects the combination whenever an override is reachable from the declared type, not only where the fragment would have applied, because a rejection is visible and resolvable while a dropped fragment is neither. A declared type graph nesting deeper than 64 levels also fails, because past that bound the absence of an override has not been proven. The search follows the declared type’s own parameterization — its type arguments, array element, and inherited container or wrapper bindings — and does not descend into the members of the types it finds. A redirect on a property whose declared type is a DTO therefore succeeds even when that DTO’s own fields carry profile-overridden types: the redirect replaces the DTO’s schema wholesale, exactly as asked, and no fragment the profile publishes elsewhere is contradicted. Use implementation only where you intend the declared type’s schema — including anything nested inside it — to be replaced.
  • A conjunction of disjoint explicit type keywords. After generation, every conjunctive location — a subschema node, its direct allOf branches, and its locally resolved $ref targets — must admit at least one explicit type across the declarations found there. The types are intersected, refined by the one subtype relation JSON Schema’s type vocabulary carries: integer is the integral subset of number, so conjoining the two narrows to integer rather than emptying — @Schema(allOf = {Integer.class}) on a double property generates normally. An empty result is an unsatisfiable contract and fails generation. anyOf and oneOf branches are alternatives, not conjunctions, so a nullable overridden property remains valid. Only a $ref this module can resolve inside the document itself — "#" or a "#/"-rooted JSON pointer — is followed; a $anchor reference such as @Schema(ref = "#anchorName"), an external URI, or an unresolvable pointer is skipped, so it never fails generation. A resolvable pointer is followed only when its target is itself a schema position (see below): #/$defs/Money and #/properties/amount are conjoined, while a pointer at data such as #/default, or at the container object under #/$defs/Money/properties, contributes nothing.
  • A @Schema(name = ...) rename that publishes two members under one name. In the profile-aware modes, a walked field that Jackson does not attach to any property of its own, and that is renamed onto a property backed by another field the schema library walks, would publish both fields under one name, the renamed one with the other’s input or output visibility and wire name. Generation instead fails with a bounded JsonSchemaGenerationException naming the type, the renamed member, and the wire name of the property it collides with. Rename one of the two properties, or name them apart on the wire with @JsonProperty. A rename onto a property no other walked field backs is unaffected and keeps its schema: the Lombok-style @Schema(name = "active") boolean isActive behind isActive() and setActive(...), or an mName field behind getName() and setName(...), publishes active or name as before. So is a rename to the member’s own property name, or to a name no property carries. Where a field name and its accessor property differ, @JsonProperty("active") on the field joins the two for Jackson as well. A rename that does not fail generation can still publish a member under a name other than the one Jackson serializes — @Schema(name = ...) onto a name no property carries is the ordinary case. AnnotationJsonSchemaGenerator#outputRenames(Type) reports every such member reachable from type’s output document — its root and every nested object, collection element, or shared definition it describes, each member once however often it is reached — as an OutputRename(declaringType, member, serializedName, schemaName), ordered by declaringType then member (String.compareTo); it returns an empty list when no reachable member’s published name diverges. The document itself is unchanged: outputRenames only detects the divergence, under the same accepted type grammar, bounded JsonSchemaGenerationException failure contract, and per-instance lock as generateCanonical. When a member carries both @JsonProperty and @Schema(name = ...), only a non-empty @JsonProperty value naming a name other than the member’s own takes precedence: @JsonProperty("wire") @Schema(name = "label") String code publishes wire, the name Jackson serializes, and is not reported. A bare @JsonProperty, or @JsonProperty("code") naming the member itself, does not take precedence: @Schema(name = "label") still publishes label while Jackson serializes code, and the member is reported as (…, code, code, label). outputRenames answers only for a generator built by forOutputProfile(JsonMapperProfile); calling it on an input-direction generator (forInputProfile) or one built by withVictoolsDefaults() throws IllegalStateException. dev.vertique:vertique-rest-openapi-docs refuses to publish an output type for which it reports a member.

@Schema(type = ...) has no effect in this module; implementation is the supported way for a property to contribute a type shape.

@Hidden alone hides nothing. A member or type marked @io.swagger.v3.oas.annotations.Hidden without @Schema(hidden = true) stays in the document either generator produces. An input member stays described, so a gate that validates against the document keeps enforcing its constraints; an output member stays published. To keep a member out, use @Schema(hidden = true) on the field or getter the generator describes it through (the two are read together), except in the cases below; @Hidden is no substitute.

@Schema(hidden = true) does not hide every member or type:

  • the input direction still describes a property the profile’s mapper binds through its setter, whether the marker sits on the property’s field or on its getter; the output direction hides it;
  • neither direction hides the flattened content of a @JsonUnwrapped member marked @Schema(hidden = true);
  • the input direction still describes a member bound through the inline nested-bean branch (a nested bean described inline because the member’s own @JsonFormat makes it case-insensitive or a member-level @Schema(additionalProperties = FALSE) closes its extras) and a member bound through a converter (@JsonDeserialize(converter = ...)), whichever declaration carries the marker;
  • the input direction still describes a property bound through a builder method, whether the marker sits on the builder method or on the built type’s field; the output direction hides it;
  • the input direction still describes a property bound through a static factory creator’s parameter, even when its backing field carries the marker; the output direction hides it;
  • the input direction still describes a map member rendered through its value constraints (a value position that carries a constraint), whichever declaration carries the marker;
  • neither direction hides an enum constant marked @Schema(hidden = true);
  • neither direction honors a @Schema(hidden = true) a mix-in declares for a member;
  • neither direction honors a @Schema(hidden = true) declared on a creator parameter itself: the generators read the property’s field or getter, so the property stays described;
  • neither direction honors a @Schema(hidden = true) that reaches a field only through a Jackson annotation bundle (an annotation meta-annotated @JacksonAnnotationsInside): the property stays described;
  • neither direction honors a @Schema(hidden = true) on a setter: the property stays described;
  • neither direction honors a class-level @Schema(hidden = true): the type is still described wherever the document references it.

Such a member or type stays in the document, and the report below lists it.

AnnotationJsonSchemaGenerator#hiddenMembers(Type) finds every member and type that type’s document describes and that carries @Hidden, @Schema(hidden = true), or both, in either direction. Each entry is a HiddenMember(declaringType, member, marker, hideableBySchemaHidden), where marker is HidingMarker.HIDDEN, SCHEMA_HIDDEN, or BOTH. A SCHEMA_HIDDEN entry is a member whose marker the generator ignores at that position (the cases above, or a marker on a declaration other than the one it reads); a member whose @Schema(hidden = true) the generator honors is left out of the document and is not reported. The document’s reach is its root and every type it describes — a nested object, a collection or array element, an Optional payload, a shared definition, and, in the input direction, a map value — together with every property described on them.

hideableBySchemaHidden answers one question: would declaring @Schema(hidden = true) on the property’s own field, or on its getter when the mapper sees no field, make this generator leave the property out at the position where the document describes it. It is true only when the position is described through a field or getter the generator checks for the marker, which includes every output-direction property. It is false for:

  • a type, an enum constant, a @JsonUnwrapped member, and an any-setter;
  • in the input direction, a property described through a setter, a builder method, a static factory creator’s parameter, or a constructor creator’s parameter without a backing field;
  • in the input direction, a map member rendered through its value constraints, a member bound through the inline nested-bean branch, and a member bound through a converter;
  • a position with no schema-library member scope, where the generator checks neither declaration.

A member reached from several positions is reported once, with the conjunction of the flags at each position. The positions listed above are the Stable contract for false; additional positions may begin returning true when the generator starts honoring @Schema(hidden = true) there — additive expansion only, never a narrowing of an existing true.

Each reported member is a Java member, reported under its own name and the class that declares it:

  • a described property’s field, getter, or setter carrying a marker, whether the mapper binds the property through one of them, through a record component, or through a creator parameter, so a marker on the getter of a private field secret reports getSecret, and a marker on a record component reports the component’s name once;
  • a creator parameter carrying a marker, named by the creator and the zero-based parameter index: <init>#0 for a constructor’s first parameter, of#1 for the second parameter of a static factory method of, under the class that declares the creator;
  • in the input direction, a builder method carrying a marker, under the builder class, and an any-setter carrying one when the document describes the type’s extra keys; the output direction describes neither. An any-setter declared on a creator parameter carrying @Schema(hidden = true) is reported like any creator parameter, as <init>#i (or method#i for a static factory), with hideableBySchemaHidden false;
  • a method carrying a marker that a described getter or setter overrides or implements, a generic declaration overridden with concrete parameter types included, named where it is declared;
  • a @JsonUnwrapped member carrying a marker, in either direction, when the document describes its flattened content, a getter-only or read-only member included;
  • a constant carrying a marker of an enum the document describes, which the document still lists, reported under the enum’s binary name and the constant’s name.

A type carrying a marker is reported with a null member wherever the document describes it: as the root, as a member’s type, as a collection or array element, inside an Optional, as a map value in the input direction, and as a polymorphic base that the document describes only through its subtypes, reached through a member at any of those positions. A described subtype of a class carrying a class-level @Schema(hidden = true) is reported for the subtype as a SCHEMA_HIDDEN type entry, because @Schema is @Inherited; @Hidden is not, so an inherited @Hidden never counts. The type’s own view decides first (a mix-in’s @Schema, then the declared one, then one reached through a bundle), and the inherited @Schema is read only when that view holds none. A class that declares @Hidden directly and inherits a hidden @Schema is reported as BOTH. The base type, when the document describes it as well, keeps its own entry.

Only a @Schema declared on a superclass is inherited, and the nearest one wins, as Java’s @Inherited defines it, so an intermediate class declaring @Schema(hidden = false) stops a hidden @Schema above it from reaching its subtypes. A @Schema on an interface is never inherited, which matches Class#getAnnotation; swagger-core reads only the declared annotation. A mix-in registered for a superclass is not inherited by a subtype either: the subtype’s own mix-in is read through its own view. To publish a subtype of a hidden class, declare its own @Schema without hidden.

A marker counts as the profile’s mapper sees it in the generator’s direction: declared on the member or type directly, through a Jackson annotation bundle (an annotation meta-annotated @JacksonAnnotationsInside) at any depth, or through a mix-in the mapper registers, that mix-in’s superclasses included, and, for a method, on a declaration the method overrides or implements. A mix-in’s marker reports the target class’s member or type, never the mix-in. A mix-in registered for a superclass reaches the members a subclass inherits and a method of the same signature that only a subclass declares. Each declaration is judged by its own markers: the same method declared on several classes of a hierarchy is reported under each declaration that carries one, with that declaration’s own marker.

@Hidden and @Schema(hidden = true) are read on fields, methods, creator parameters, enum constants, and types. @Hidden targets methods, fields, and types only, so a creator parameter carries it only through an application’s own bundle annotation with a parameter target; @Schema(hidden = true) can sit on a creator parameter directly and is read there. A profile whose mapper disables annotation processing (MapperFeature.USE_ANNOTATIONS) sees no marker at all, so nothing is reported, while the generator still honors @Schema(hidden = true).

A member the document does not describe is not reported: one that @Schema(hidden = true) hides, and one the profile’s mapper does not bind (input) or serialize (output), such as a @JsonIgnore member. Each entry is reported once however often the document reaches it.

The list is ordered by declaringType, then member (String.compareTo), with a type’s own entry first within its declaring type; it is unmodifiable, and empty when nothing carries a marker. The call runs one generation under the same accepted type grammar, bounded JsonSchemaGenerationException failure contract, per-instance lock, and restore-on-failure behavior as generateCanonical, and it changes nothing a later call on the instance publishes. A generator built by either forInputProfile overload or by forOutputProfile answers for its own direction; one built by withVictoolsDefaults() throws IllegalStateException.

Both post-generation passes — the disjoint-type check above and numeric-keyword suppression — traverse the generated document through Draft 2020-12 subschema positions only, starting at the document root. They descend into not, if, then, else, items, contains, additionalProperties, propertyNames, unevaluatedItems, unevaluatedProperties, contentSchema, the branches of allOf, anyOf, oneOf, and prefixItems, and the member values of properties, patternProperties, $defs, and dependentSchemas. Nothing else is descended. The object that is the value of properties, patternProperties, $defs, or dependentSchemas is a container, not a schema: its keys are member names, so a property literally named type or allOf is read as a name and never as a keyword. A boolean true/false schema is a legal subschema and is reached, but neither pass has a keyword to read on one.

This matters when an override fragment carries JSON data. Draft 2020-12 treats an unrecognized keyword as an annotation — arbitrary data — and the values of default, const, enum, and examples are data even though the keywords are defined. Such a value is never read as a schema: an object in a default position keeps every member it declares, including a minimum the numeric filter would otherwise strip, and an object in a const position that happens to look like an unsatisfiable schema does not fail generation. A definitions member is data for the same reason — this generator publishes definitions under $defs, which a fragment may not declare, so a definitions member can only have come from a fragment as annotation content.

The same rule decides which $ref targets are conjoined, because a @Schema(ref = "#/...") value reaches the document verbatim and may point anywhere in it. A pointer is followed only when it resolves to one of the positions listed above; a pointer at a data value or at a container object is skipped exactly as an unresolvable one is. So @Schema(ref = "#/$defs/Money") and @Schema(ref = "#/properties/amount") keep contributing their target’s type to the referring location, while @Schema(ref = "#/default") neither fails generation on the data’s type nor lets that type decide whether the referring location’s numeric keywords are suppressed.

Reaching a real subschema is unaffected: a genuine type conflict at any of the positions listed above still fails generation.


The types below are public only so sibling framework modules can call them. Their Javadoc marks them INTERNAL; they are not an application contract and sit outside this module’s application maturity promise. Applications must not construct or catch them.

The single entry point. Construct with withVictoolsDefaults(), forInputProfile(profile), or forOutputProfile(profile), then call generateCanonical(Type) for each type that needs a schema, or describe(Type) for the same document paired with its RedactionManifest (see CanonicalSchema below). An instance built by forOutputProfile(profile) also answers outputRenames(Type), the list of published members whose schema name diverges from what the profile mapper serializes (see OutputRename below); every other construction mode throws IllegalStateException from that call. An instance built by either forInputProfile overload or by forOutputProfile(profile) answers hiddenMembers(Type) for its own direction: the members and types its document describes that carry @Hidden, @Schema(hidden = true), or both (see HiddenMember below and “Constraints and common mistakes” above); an instance built by withVictoolsDefaults() throws IllegalStateException from that call.

AnnotationJsonSchemaGenerator generator = AnnotationJsonSchemaGenerator.withVictoolsDefaults();
String schemaJson = generator.generateCanonical(MyRequestBody.class);

A profile-aware consumer supplies its already-resolved JsonMapperProfile and the direction that matches the schema’s role:

AnnotationJsonSchemaGenerator inputGenerator =
AnnotationJsonSchemaGenerator.forInputProfile(resolvedProfile);
String argumentSchema = inputGenerator.generateCanonical(toolArgumentType);

The record describe(Type) returns: the canonical document text (json()) paired with the RedactionManifest bound to it (redactionManifest()). json() equals generateCanonical(Type)’s output for the same type only for an instance describe produced: the record is public and anyone can construct one, pairing any text with any manifest, so trust the pairing only when redactionManifest().matches(json()) holds. Both components are required; the compact constructor rejects null.

CanonicalSchema described = generator.describe(MyRequestBody.class);
String schemaJson = described.json(); // == generator.generateCanonical(MyRequestBody.class)
RedactionManifest manifest = described.redactionManifest();

The sorted RFC 6901 pointers of every reserved-name guard in one CanonicalSchema’s document, bound to the SHA-256 digest of that document’s canonical bytes. Only this package constructs one — no public or protected constructor, and the class is final — so a caller cannot forge a manifest. CanonicalSchema, however, is a plain record anyone can construct, so a consumer trusts the pairing only when redactionManifest().matches(json()) holds; a borrowed manifest fails that check for different JSON. pointers() is unmodifiable and may be empty; digest() is "sha-256:" plus 64 lowercase hexadecimal characters; matches(schemaJson) parses, canonicalizes, digests, and compares, returning false for any unparsable or malformed input — a document repeating a key in one object included — and throwing NullPointerException for a null argument. toString() prints the pointers and the digest only — a pointer names a schema location, never a reserved name, and no schema content is printed.

One member outputRenames(Type) (on a forOutputProfile generator only) reports: declaringType is the binary class name (Class#getName()) declaring the member, member is the Java member’s own name (java.lang.reflect.Member#getName()), serializedName is the name the profile mapper’s serialization introspection gives it, and schemaName is the property name the output document publishes it under. The record carries names only, never a schema fragment. OpenAPI publication consumes this shape; components evolve only by appending nullable trailing fields.

AnnotationJsonSchemaGenerator outputGenerator = AnnotationJsonSchemaGenerator.forOutputProfile(profile);
List<OutputRename> renames = outputGenerator.outputRenames(MyResponseBody.class);
// renames is empty unless some published member's schema name differs from its serialized name

One entry hiddenMembers(Type) reports: declaringType is the binary class name (Class#getName()) of the class declaring the member, or of the type itself; member is the Java member’s own name (java.lang.reflect.Member#getName()), the name of a field, a method, or an enum constant, a creator parameter’s <init>#i or method#i name, or null when the entry reports the type itself (the component is annotated jakarta.annotation.Nullable); marker is the HidingMarker the declaration carries; hideableBySchemaHidden says whether declaring @Schema(hidden = true) on the property’s own field or getter would leave it out (see “Constraints and common mistakes” above for the Stable position rules). The record carries names and flags only, never a schema fragment.

AnnotationJsonSchemaGenerator inputGenerator =
AnnotationJsonSchemaGenerator.forInputProfile(resolvedProfile);
List<HiddenMember> hidden = inputGenerator.hiddenMembers(MyRequestBody.class);
// empty unless a described member or type carries @Hidden or @Schema(hidden = true)

The enum of a HiddenMember’s markers: HIDDEN for @Hidden alone, SCHEMA_HIDDEN for @Schema(hidden = true) alone, BOTH for the two together.

The one bounded failure type this module throws, with a value-free, length-bounded message and the original cause preserved when one exists. Consumers catch and translate this exception without depending on any Victools exception type.

getMessage() is safe to log verbatim. It is at most 512 UTF-16 code units — a hard bound, not an approximation — and carries no code point that could terminate a log record, forge a second one, or reorder the identity it renders: every Unicode Cc control (including the C1 block and NEL), Cf format character (including the Trojan-Source bidirectional overrides and isolates, SOFT HYPHEN, and the byte-order mark), Zl, Zp, and unpaired surrogate is replaced one-for-one with ? before the message is bounded, and an elision never splits a surrogate pair. Identity a type, property, or profile contributed therefore stays readable while a hostile name can neither inject a line break nor make the message read as naming a different type or profile than the one that failed. The one stated limitation: a supplementary-plane Cf code point (U+110BD, U+1D173–U+1D17A) survives, since none of them reorders or terminates rendered log text.

That guarantee covers the message only. The attached cause is not sanitized or bounded — it is preserved raw, deliberately, because it is what makes a failure diagnosable. A conventional log.error("…", ex) renders Caused by: <cause message>, which may be arbitrary third-party or application text. Log the cause where that is acceptable; log getMessage() alone where it is not.


None for applications. This module exposes a closed generation surface — no schema-generator SPI, custom Victools module registration, or configuration key. A framework consumer that needs a custom wire shape for an exact Java class declares it once on its JsonMapperProfile through dev.vertique.core.json.JsonSchemaTypeOverride, which this module’s profile-aware construction modes consume.


None. vertique-json-schema is a plain library artifact: applications construct AnnotationJsonSchemaGenerator through its static factories. There is no Dagger @Module.

Artifact Scope Purpose
dev.vertique:vertique-core compile JsonMapperProfile, JsonSchemaFragment, JsonSchemaTypeOverride — the stable JSON profile contracts this module consumes
com.fasterxml.jackson.core:jackson-databind compile ObjectMapper property discovery that Victools’ Jackson module introspects
jakarta.validation:jakarta.validation-api compile Jakarta Validation constraint annotations Victools’ Jakarta Validation module introspects; also the Validator/constraint-metadata types forInputProfile(profile, Validator)’s metadata constraint source reads. No hibernate-validator (or any other provider implementation) dependency — Hibernate’s @Length/@Range/@URL are recognized by fully-qualified annotation class name, never by simple name alone, keeping this module provider-agnostic
io.swagger.core.v3:swagger-annotations-jakarta compile @Schema / @ArraySchema annotations Victools’ Swagger 2 module introspects
com.github.victools:jsonschema-generator compile The Draft 2020-12 schema generation engine
com.github.victools:jsonschema-module-jackson compile Jackson property discovery module
com.github.victools:jsonschema-module-jakarta-validation compile Jakarta Validation constraint mapping module
com.github.victools:jsonschema-module-swagger-2 compile Swagger 2 annotation mapping module