Query Model Schema
What the Schema provides
QueryModelSchema publishes immutable facts: one shared LogicalQuerySchema value tree and backend bindings indexed by QueryPathTemplate. The Schema neither executes queries, makes authorization decisions, nor rewrites requests. The Gateway validates logical input; the Backend consumes bindings to compile native expressions.
Recursive value tree
QueryValueSchema.kind distinguishes SCALAR, OBJECT, ARRAY, NULL, UNION, and UNKNOWN:
- OBJECT has named
propertiesand a typed Map default inadditionalProperties. A named property overrides that default. - ARRAY keeps its member definition in
items; container valueTypes and temporal semantics do not copy member facts. - UNION preserves
alternatives. UNKNOWN preserves uncertainty and cannot justify operand capabilities. - Values may carry title, description, enumValues, nullable, required, and semanticType. Executable masking rules remain in memory; the capability descriptor exposes only a field's
sensitivity.
For example, a Map<String, List<Address>> declaration:
querySchemaRegistration(Order::class, QueryModel.SNAPSHOT) {
field("state.addresses") {
values {
items {
property("city") { types(QueryValueType.STRING) }
}
}
}
}state.addresses.home is an object array; use relative city inside elementMatch. state.addresses.home.city.extra is unknown and never falls back to a physical path. Equality, membership, and range operations on primitive arrays use one direct items layer, without flattening a second anonymous array. Scalars, containers, and Map values retain separate definitions.
Field Aliases and Deprecation
Rename a field without breaking callers with @QueryAlias (from me.ahoo.wow.api.query.annotation), and mark a field kept only for old callers with Kotlin's @Deprecated:
data class OrderState(
@field:QueryAlias("state.customer")
val buyer: Buyer,
@Deprecated("Use state.buyer.")
val customerName: String,
)- An alias is a full logical path. Filters, sorts, projections and aggregations may use it, or a path below it (
state.customer.name); admission replaces it with the canonical path before anything else sees the query. - Results and projections only contain canonical names. Sort uniqueness, sensitivity and cursors are decided by the canonical name, so an alias can never bypass a field's protection.
- An alias that names an existing field, is claimed by two fields, or sits under a map key fails schema compilation.
- The capability descriptor lists each field once, by its canonical path, with its
aliases; a deprecated field stays queryable and carriesdeprecated({ "message": … }).
Source Priority and Merging
The runtime source chain is below. A larger number means a higher priority:
Systemsupplies model-specific fields for Snapshot and EventStream. Extensions must remain under the Snapshotstateroot or the EventStreambody.bodyroot; a field leaf already set by System cannot be overwritten.InferredQuerySchemaSource (100)infers Snapshot fields from the aggregate state's JSON shape and EventStreambody.body.*fields from domain-event payloads, one variant per event type tagged with itsbodyType. Type inference is aQueryModelSourcebean: the defaultJsonQueryModelSource(wow-schema) reports only raw facts of the serialized JSON (paths, types, nullability, enums, format hints, member annotations); what they mean for queries is decided in wow-query. Standard time types are temporal automatically;@QueryTemporal(unit = TimeUnit.SECONDS)declares an integer epoch timestamp and@QueryTemporal(pattern = "yyyy-MM-dd")a formatted string time;@QueryDecimaland@QueryMoneydeclare decimal and money precision,@QueryDurationand@QueryReferencedurations and references (all fromme.ahoo.wow.api.query.annotation); a property of typeAggregateIdis a reference without an annotation.@Sensitiveis described in Field Masking.ClasspathQuerySchemaSource (200)readsMETA-INF/wow/query-schema/{context}.{aggregate}.{model}.json;WorkingDirectoryQuerySchemaSource (400)readsconfig/wow/query-schema/{context}.{aggregate}.{model}.json. The model segment is lowercase:snapshotorevent_stream; the dot is the reserved Wow named-aggregate delimiter. The 9.1 fallback locationwow-query-schema/{context}/{aggregate}/{model}.jsonis not read. When no source (classpath, working directory or registration bean) declares that model in 9.2, a file there is logged as a warning naming where the 9.2 file goes, and the model uses its inferred schema;wow.query.schema.legacy-declarations=failfails that model's schema instead. The 9.1 location is scanned once per model, on its first load; a periodic revalidation does not scan it again. See moving a 9.1 declaration below.BeanQuerySchemaSource (300)mergesQuerySchemaRegistrationentries for the current context.
Declaration files and code registration
A declaration only supplements what inference cannot know — mostly values behind Map, JsonNode or Any — in the capability descriptor's vocabulary:
{
"fields": {
"state.status": { "types": ["STRING"], "enum": [{ "value": "PAID", "description": "Paid" }, { "value": "SHIPPED" }] },
"state.placedOn": { "types": ["STRING"], "semantic": { "type": "TEMPORAL_FORMATTED", "pattern": "yyyy-MM-dd" } },
"state.attributes": { "kind": "OBJECT", "values": { "kind": "ARRAY", "items": { "types": ["STRING"], "nullable": false } } }
}
}| Key | Meaning |
|---|---|
kind | SCALAR, OBJECT or ARRAY; implied by types, properties/values or items when omitted. Unions, null and unknown values are inferred, never declared. Since 9.3.0 OBJECT opens a value inference records as unknown (an ObjectNode or Any field), so its properties can be declared; it never turns a scalar or array into an object |
types | Scalar types: STRING, INTEGER, DECIMAL, BOOLEAN |
nullable | Whether JSON null occurs |
enum | The declared values, each { "value": …, "description"?: … }; descriptions reach the descriptor's enum |
semantic | One semantic type: a time encoding, TEMPORAL_EPOCH (timeUnit), TEMPORAL_DATE, TEMPORAL_FORMATTED (pattern); or a numeric format, DECIMAL (scale), MONEY (currency or currencyField, scale), see below; or DURATION (timeUnit), REFERENCE (contextName and aggregateName, or contextNameField and aggregateNameField), see below |
description | What the field means |
properties, items, values | Named properties of an object, the element of an array, the value of every key of a map |
Any other key is rejected. Sensitivity, aliases and deprecation are only declared on the domain field (@Sensitive, @QueryAlias, @Deprecated); display names belong to view definitions. querySchemaRegistration { field(...) { … } } uses the same vocabulary: kind, types, nullable, enumValue(value, description), semantic/temporalEpoch/temporalFormatted, description, property, items, values.
Moving a 9.1 declaration file
9.1.5 reads META-INF/wow/query-schema/{context}.{aggregate}.{model}.json (and config/wow/query-schema/… in the working directory) first, and only when neither exists falls back to wow-query-schema/{context}/{aggregate}/{model}.json; every file is in the 9.1 format. 9.2 reads only the first two paths, only in the 9.2 format. Both versions reject keys they do not know, so in a cluster that runs 9.1 and 9.2 nodes side by side, or with a domain module read by both:
- a 9.1-format file at
META-INF/wow/query-schema/…orconfig/wow/query-schema/…is not read by 9.2 either: it fails that model's schema on a 9.2 node (Unknown query schema properties); - a 9.2-format file at those paths fails it on a 9.1 node, unless it uses only
kind,nullable,description,propertiesanditems, which both versions read; - a file at
wow-query-schema/…is read by 9.1 nodes only; a 9.2 node logs a warning and serves the model's inferred schema (the defaultwow.query.schema.legacy-declarations=warn).
To keep a model declared on both sides during the upgrade, either:
- Per node: keep the 9.1 file at
wow-query-schema/{context}/{aggregate}/{model}.jsononly (not atMETA-INF/wow/query-schema/…), and give each 9.2 node its 9.2 file in its working directory atconfig/wow/query-schema/{context}.{aggregate}.{model}.json. 9.1 nodes fall back to the 9.1 file; 9.2 nodes read the working-directory file and only log that the 9.1 file is ignored. - Shared subset: write one file at
META-INF/wow/query-schema/{context}.{aggregate}.{model}.jsonthat uses only the keys both versions read (above), withoutnullvalues.
Once no 9.1 node reads it, move the file to META-INF/wow/query-schema/{context}.{aggregate}.{model}.json (or config/wow/query-schema/…) in the 9.2 format, delete the 9.1 file, and optionally set wow.query.schema.legacy-declarations=fail so a forgotten 9.1 file fails its model. The keys change as follows; 9.2 accepts no null values (omit the key instead):
| 9.1 key | 9.2 key |
|---|---|
valueTypes | types (declarable: STRING, INTEGER, DECIMAL, BOOLEAN) |
enumValues: [v, …] | enum: [{ "value": v }, …] |
semanticType | semantic |
additionalProperties | values |
kind, nullable, description, properties, items | unchanged; kind is SCALAR, OBJECT or ARRAY |
title | removed: display names belong to view definitions |
required, alternatives | removed: inferred, never declared |
QuerySchemaMerger processes priorities from low to high. A later, higher-priority source overrides only leaves that it explicitly sets; unset leaves keep their lower-priority values. Different values for the same leaf at the same priority raise a Schema conflict instead of depending on load order. Refresh reloads sources and backend facts for the current process and replaces its cache only when the schema changed: when its version and the storage's bindings are unchanged, the published schema and its cached descriptors are kept. It does not change indexes, mappings, validators, or historical data.
Decimal and money precision
A numeric field can declare how it is meant to be read, so the view engine and agents format and total it correctly. It is display and totalling semantics, not a storage rule: it changes no query and adds no capability.
DECIMAL(scale): a fixed-point decimal withscalefraction digits.MONEY: an amount in exactly one of a fixed ISO 4217currency(such asCNY) or the currency held by a sibling string propertycurrencyField. With a fixed currency,scaledefaults to the currency's standard fraction digits (2 forCNY, 0 forJPY; currencies without one, such asXAU, must give it); withcurrencyFieldit is required.
data class OrderState(
@field:QueryDecimal(scale = 4) val exchangeRate: BigDecimal,
@field:QueryMoney(currency = "CNY") val total: BigDecimal,
@field:QueryMoney(currencyField = "currency", scale = 2) val paid: BigDecimal,
val currency: String,
)In a declaration file: "semantic": { "type": "DECIMAL", "scale": 2 } or "semantic": { "type": "MONEY", "currency": "CNY" }. Precision is never inferred: a BigDecimal does not tell it, and a wrong guess is worse than none.
Building the Schema rejects a wrong declaration as a Schema conflict instead of ignoring it: the field must be numeric; currencyField must be a single-valued string property of the same object (for a field inside an element, the same element); exactly one of currency and currencyField is given; and a field has one semantic type, so it cannot also be temporal. The descriptor publishes the format in the field's semantic, with the resolved scale, e.g. { "type": "MONEY", "currency": "CNY", "scale": 2 }.
Durations and references
Two more facts a field can state, again for reading only: they change no query and add no capability, on MongoDB or Elasticsearch.
DURATION(timeUnit): a number that is a length of time intimeUnit, such as a timeout in seconds, so the view engine formats it as a duration instead of writing the unit into a label. Declared with@QueryDuration(unit); the unit is required, since anIntdoes not tell it.REFERENCE: a value that is the id of an aggregate, for looking it up and linking to it. Either a fixed aggregate,contextNameandaggregateName, declared with@QueryReference(aggregateName, contextName), where an omittedcontextNameis the declaring model's own bounded context; or, per record, the aggregate named by the sibling string propertiescontextNameFieldandaggregateNameField. The second form is inferred for every property of typeAggregateId: itsaggregateIdrefers to the aggregate its owncontextNameandaggregateNamename. A reference points at the aggregate's id, never another key. The records' ownaggregateIdcarries no reference: its role,AGGREGATE_ID, and the endpoint already say which aggregate it is.
interface IRetrySpec {
@get:QueryDuration(TimeUnit.SECONDS) val minBackoff: Int
}
data class OrderState(
@field:QueryReference("member") val memberId: String,
@field:QueryReference("product", contextName = "catalog") val productIds: List<String>,
val source: AggregateId,
)In a declaration file: "semantic": { "type": "DURATION", "timeUnit": "SECONDS" } or "semantic": { "type": "REFERENCE", "contextName": "example", "aggregateName": "member" }. Building the Schema rejects a duration on a field that is not numeric, a reference on one that is not a string or an integer (an array of them is fine), sibling name fields that are not single-valued strings of the same object, and a second semantic type on the field. The referenced aggregate is checked by name syntax only, since it may live in another service.
A client reading a descriptor may see a semantic type its version does not know yet, from a newer server: the Kotlin QuerySemanticType reads it as QuerySemanticType.Unknown, which a client treats as no semantic type. A declaration file or registration that names an unknown type is still rejected.
Native bindings and capabilities
QueryPathTemplate explicitly distinguishes Property, Item, and Key. QueryValueBindings stores per-capability QueryFieldBindingTemplate(physicalPath, storageTypes), plus projectionPath and responsePath. A concrete schema.field(QueryField(...)) returns the value, complete element ancestry, and concrete bindings. A fixed key's native constraints cannot be bypassed by a Map default.
The MongoDB adapter reads indexes and optional validator facts, retaining array/items/additionalProperties and composed type evidence separately. Missing native facts may use trusted declarations and known codecs; known conflicts are rejected. A Temporal.Date field gains EQ/RANGE only where the validator declares it a BSON date (not a timestamp); its operands, ISO-8601 dates or date-times or epoch milliseconds, compile to BSON dates. Temporal aggregation also needs native date-type evidence. Elasticsearch uses mapping, nested, multi-field, doc values, alias, and runtime facts. Neither adapter guesses native paths from caller input.
| Capability | Purpose |
|---|---|
| PRESENCE | Existence, absence, null, empty collections |
| EXACT_MATCH / LITERAL_MATCH / RANGE | Exact values, literal strings, range comparisons |
| FULL_TEXT_TERMS / FULL_TEXT_PHRASE | Supported model or field full-text searches |
| SORT / CURSOR_SORT | Ordinary sorting / independent cursor sorting |
| ELEMENT_SCOPE | Enter a proven object-array element scope |
| AGGREGATE_TERMS / AGGREGATE_NUMERIC / AGGREGATE_TEMPORAL | Terms, numeric, and temporal aggregation |
Numeric EXACT_MATCH/RANGE compares at native storage precision, not arbitrary-precision source equality; see numeric comparisons. AGGREGATE_NUMERIC does not automatically expand arrays: direct fields and arithmetic leaves follow the numeric contribution contract. Logical declarations and runtime output must obey that numeric model. Precision remains a Backend native fact; no public precision or scalingFactor field is added.
Masking does not remove native capability facts. Public cursor and aggregation admission separately reject protected values and their native aliases. Public metadata supports discovery, not a replacement for final request validation.
Strict admission and revalidation
Unknown fields or suffixes, missing capabilities, incompatible values, and incomplete element scopes fail closed. There is no configurable permissive field fallback. Public queries retain logical paths. Admission checks and resolves every field reference in one pass: QueryAdmission.Trusted returns an AdmittedQuery whose query keeps logical names and carries each reference's physical binding beside it; no physical Query is produced.
Each Gateway subscription uses one Schema version: preparation, admission and response masking read it, and the Backend's compilers consume the fields admission resolved against it, carried by the AdmittedQuery. Provider failures are not cached as successful values and never bypass validation. Revalidation (every wow.query.schema.revalidate-interval, or on demand through the wowQuerySchema actuator endpoint) publishes a new version; a subscription already running keeps its own. Direct Backend callers obtain an AdmittedQuery via QueryAdmission.Trusted; see Query Backend.
HTTP and OpenAPI
GET snapshot/schema and GET event/schema return the model's capability descriptor for the HTTP entry: how this model can be queried over HTTP. Storage facts (indexes, mappings, validators) change outside deployments, so each instance reloads every query schema every wow.query.schema.revalidate-interval (default 5m, 0s disables); a schema that fails to compile keeps its previous version and the failure is logged. With Spring Boot Actuator, the wowQuerySchema endpoint lists this instance's schema versions (read) and revalidates now, optionally for one aggregate (write). The 9.1 routes POST /{aggregate}/snapshot/schema/refresh and POST /{aggregate}/event/schema/refresh remain as deprecated aliases until 10.0.0: each reloads its own model (Snapshot or EventStream) of that aggregate on the instance that answers, concurrent calls sharing the reload in flight, then returns the descriptor as GET …/schema does. The descriptor publishes conclusions, not storage facts:
fields: one entry per logical path (element fields use their full path and name their element inscope), with itsrole(on a system field: a system-field filter target such asAGGREGATE_IDorTENANT_ID, or one of the model's times,EVENT_TIMEon a Snapshot'seventTimeand an EventStream'screateTime,FIRST_EVENT_TIMEon a Snapshot'sfirstEventTime),types,kind,semantic,enum,sensitivity,deprecated,aliases, thefilter.operatorsit admits,sort(paged,cursor) andaggregate(groups, functions,distinctCount,percentile,any,inMetricFilter, …);record: identity, paging modes, default deletion scope, root operators and full-text search (search.modesfor a model-wideSEARCH,search.fieldsfor record-level fields);limits: effective limits of the HTTP entry (budget and protocol limits, whichever is smaller;nullis unlimited) anddefaultListSize;analysis: the metric types,approximate(those this backend estimates:PERCENTILEon MongoDB;DISTINCT_COUNTandPERCENTILEon Elasticsearch),dateUnitsforDATE_HISTOGRAM,datePartsforDATE_PART,dateDiffUnitsforDATE_DIFF(empty when expressions are not allowed), having, sort and dense support;elements(each withsearch: the fields aSEARCHinsideELEMENT_MATCHon it may name, and the modes all of them accept; absent when storage can search none),dynamic(map keys as{key}, one entry per pattern, array items implicit as for fields) andconstraints(e.g.CURSOR_UNIQUE_SORT, andCOUNT_REQUIRES_FILTER/STARTS_WITH_REQUIRES_PREFIXwhen expensive operators are off, andPARALLEL_ARRAY_SORTwith the array-valued sort fields of which a sort may name at most one when the storage, such as MongoDB, cannot sort by two independent arrays; on Elasticsearch,NULL_OR_EMPTY_AS_MISSINGwith the fields whose storednullor empty array presence operators read as missing, andARRAY_EQUALITY:EQ/NEtake only a scalar operand there, and an array operand is rejected with that code).variants(EventStream only): thebodyelement's event types, keyed by the discriminatorbodyType, each with its description and its payloadfieldsrelative to the element (body.amount). A condition on one event's field goes inside anELEMENT_MATCHonbodytogether withbodyType, so both apply to the same event.
Everything listed is admitted when used on its own; anything unlisted is rejected. Values, scopes and policies can still reject a query at run time, with a bindingErrors code. The descriptor never contains physical paths, storage types or Mask strategies. version is a hash of its content and doubles as the ETag: send If-None-Match to get 304 while it is unchanged. A browser on another origin can read the ETag header only when the server lists it in Access-Control-Expose-Headers; Wow does not own the CORS configuration, so add it there (for example exposedHeaders("ETag") in a Spring CorsConfiguration). A client that reads version from the body needs no header.
x-wow-query-fields remains a static candidate-field extension on Snapshot request-body components. It is not a request field or proof of runtime capability. The API Client does not replace server-side runtime Schema discovery or validation.