From 036e5409d73766a89706e5f95147c3842623b43e Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Wed, 27 May 2026 11:10:29 +0300 Subject: [PATCH 1/7] docs(spec): frame GTS as a JSON Schema extension (dialect-agnostic) and add ADR-0001 on derivation form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite §11.0 to frame GTS Type Schemas as an extension of JSON Schema with vendor x-gts-* keywords and registry-enforced semantic rules. The framing is dialect-agnostic: implementations MUST honour whichever JSON Schema dialect is declared in $schema. The spec's examples use Draft-07 as the baseline for maximum interoperability, but Draft 2019-09 and Draft 2020-12 are equally acceptable. Post-Draft-07 keywords ($defs, prefixItems, unevaluatedProperties, etc.) MAY be used provided the chosen $schema admits them. GTS is not a JSON Schema Dialect in the formal sense (no dedicated $schema URI or meta-schema); GTS-specific constraints are enforced at the registry. - Rename §11.0 to "Relationship to JSON Schema". - Update Terminology entry for "GTS Type Schema" to match the dialect-agnostic framing (the spec's examples use Draft-07 as baseline; 2019-09 / 2020-12 are equally supported). - Clarify §3.2 and OP#12 wording so that derivation compatibility applies regardless of whether the derived schema references the parent via allOf + $ref or re-declares parent fields directly. - Clarify §9.11 finality guard is determined from the chained $id alone, not the body's use of allOf. - Add adr/0001-derivation-form.md documenting the decision to treat GTS as a JSON Schema extension (dialect-agnostic; not a formal Dialect) and not mandate allOf for derivation. Signed-off-by: Aviator 5 --- README.md | 22 ++-- adr/0001-derivation-form.md | 255 ++++++++++++++++++++++++++++++++++++ 2 files changed, 268 insertions(+), 9 deletions(-) create mode 100644 adr/0001-derivation-form.md diff --git a/README.md b/README.md index e3171c6..ac8deea 100644 --- a/README.md +++ b/README.md @@ -119,7 +119,7 @@ This specification uses the following terms with precise meanings: - **GTS Type**: a type entity identified by a GTS Type Identifier and defined by a GTS Type Schema. A GTS Type may exist as a standalone document (e.g., a `*.schema.json` file), be exchanged between systems, or be stored in a GTS Registry. - **GTS Type Identifier**: a canonical GTS identifier ending with `~` that identifies a GTS Type. -- **GTS Type Schema**: the canonical definition of a GTS Type — a JSON Schema document annotated with the GTS vocabulary (`x-gts-*`), describing the type's instance shape, traits, metadata, and relations. +- **GTS Type Schema**: the canonical definition of a GTS Type. It is an **extension of JSON Schema** that adds GTS-specific keywords (`x-gts-*`) and a set of registry-enforced semantic rules describing the type's instance shape, traits, and derivation. GTS is **dialect-agnostic**: the underlying JSON Schema dialect of any concrete Type Schema is set by its `$schema` (the spec's examples use Draft-07 as the baseline for maximum interoperability, but Draft 2019-09 and 2020-12 are equally supported). GTS does not publish a dedicated `$schema` URI or meta-schema and is therefore not a [JSON Schema Dialect](https://json-schema.org/learn/glossary#dialect) in the formal sense; see §11.0 for details. Implementations MAY accept alternative source forms (e.g., TypeSpec, YAML) provided they deterministically map to a canonical GTS Type Schema. The canonical form, used for interchange, validation, and registration, is the JSON Schema document. - **GTS Registry**: a registry that stores and resolves GTS entities — Type Schemas and well-known Instances — by GTS Identifier. @@ -321,7 +321,7 @@ GTS identifiers may be chained (e.g. `gts.A~B~C`). Validation MUST respect the l - **Schema → schema validation** (validate a derived schema against its predecessor schema): - Given a derived type identifier chain (e.g. `A~B~` or `A~B~C~`), the system MUST validate that each derived schema is compatible with its immediate predecessor in the chain. - The compatibility rule is: every valid instance of the derived schema MUST also be a valid instance of the base schema. - - When JSON Schema inheritance is expressed via `allOf` (recommended), the derived schema MUST be written such that it does not invalidate the compatibility guarantee. + - The derived schema MUST be written such that it does not invalidate this compatibility guarantee, regardless of how the parent's constraints are expressed: via `allOf` with a `$ref` to the parent (recommended, to avoid duplication of parent fields) or by re-declaring the parent's fields directly in the derived schema. See §11.0 for how GTS extends JSON Schema and [`adr/0001-derivation-form.md`](adr/0001-derivation-form.md) for the full discussion. - **`additionalProperties` and adding new properties**: - If a base schema (or any schema in the inheritance chain) defines an object with `additionalProperties: false`, then derived schemas MUST NOT introduce new properties at that object level that would be rejected by the base schema. @@ -1294,7 +1294,7 @@ Implement and expose all operations OP#1–OP#13 listed above and add appropriat - **OP#9 - Version Casting**: Transform instances between compatible MINOR versions - **OP#10 - Query Execution**: Filter identifier collections using the GTS query language - **OP#11 - Attribute Access**: Retrieve property values and metadata using the attribute selector (`@`) -- **OP#12 - Type Derivation Validation**: Validate that a derived type correctly extends its base chain. Today this includes JSON Schema-level constraint compatibility (derived schemas using `allOf` must conform to all constraints defined in their parent schemas throughout the inheritance hierarchy — `additionalProperties`, narrowing/widening, etc.) and trait inheritance from OP#13. This ensures type safety in extension and prevents constraint violations in multi-level type hierarchies. When validating derived types, if any base in the chain is marked `x-gts-final: true`, validation MUST fail (see section 9.11) +- **OP#12 - Type Derivation Validation**: Validate that a derived type correctly extends its base chain. Today this includes JSON Schema-level constraint compatibility (every derived schema MUST conform to all constraints defined in its parent schemas throughout the inheritance hierarchy — `additionalProperties`, narrowing/widening, etc. — regardless of whether the derived schema references the parent via `allOf` + `$ref` or re-declares parent fields directly) and trait inheritance from OP#13. This ensures type safety in extension and prevents constraint violations in multi-level type hierarchies. When validating derived types, if any base in the chain is marked `x-gts-final: true`, validation MUST fail (see section 9.11) - **OP#13 - Schema Traits Validation**: Validate schema traits (`x-gts-traits-schema` / `x-gts-traits`). See section 9.7 for full semantics and validation rules. ### 9.3 - GTS entities registration @@ -1560,7 +1560,7 @@ A **schema modifier** is a boolean annotation on a GTS Type Schema that restrict When a schema declares `"x-gts-final": true`: -1. **Registration guard**: When a new schema is registered whose `allOf` / `$ref` chain references a final type as a base, the registry MUST reject the registration (when validation is enabled). Specifically, if the derived schema's `$id` is of the form `gts://gts.A~B~` and schema `A~` has `"x-gts-final": true`, then registering `A~B~` MUST fail. +1. **Registration guard**: When a new schema is registered whose **`$id` chain** references a final type as a base, the registry MUST reject the registration (when validation is enabled). Specifically, if the derived schema's `$id` is of the form `gts://gts.A~B~` and schema `A~` has `"x-gts-final": true`, then registering `A~B~` MUST fail. This is determined from the chained `$id` alone — it does not depend on whether the derived schema body uses `allOf` to reference the parent. 2. **Validation via `/validate-type-schema` (OP#12)**: When validating a derived schema against its base chain, if any base schema in the chain is marked `x-gts-final`, validation MUST fail with an error indicating that the base type is final and cannot be extended. @@ -1695,13 +1695,17 @@ Result: ❌ NO MATCH (different major versions) ## 11. JSON and JSON Schema Conventions -### 11.0 JSON Schema Dialect +### 11.0 Relationship to JSON Schema -GTS Type Schemas are defined in terms of **JSON Schema Draft-07**. Implementations MUST treat the GTS keywords described in this specification (`$id`, `$ref`, `allOf`, `const`, `x-gts-traits-schema`, `x-gts-traits`, `x-gts-final`, `x-gts-abstract`, etc.) as layered on top of Draft-07 semantics. +GTS Type Schemas **extend JSON Schema** with a vendor keyword set (`x-gts-*`) and a set of **registry-enforced semantic rules** (see §3.2 derivation, §9.11 modifiers, OP#12 derivation compatibility, OP#13 trait validation). GTS does **not** impose additional syntactic restrictions on otherwise-valid JSON Schemas: any syntactically valid JSON Schema that carries a valid GTS `$id` is a syntactically valid GTS Type Schema. Implementations MUST treat the GTS keywords described in this specification (`x-gts-traits-schema`, `x-gts-traits`, `x-gts-final`, `x-gts-abstract`, etc.) as layered on top of the underlying JSON Schema dialect's semantics, alongside the standard JSON Schema keywords (`$id`, `$ref`, `allOf`, `const`, …) used here. -- The `$schema` field of every GTS Type Schema MUST be `http://json-schema.org/draft-07/schema#`. -- Implementations MAY accept other JSON Schema dialects for non-GTS schemas, but MUST NOT rely on keywords introduced after Draft-07 (such as `prefixItems`, `unevaluatedProperties`, `unevaluatedItems`, `$dynamicRef`/`$dynamicAnchor`, `dependentRequired`, `dependentSchemas`) when interpreting GTS Type Schemas. -- Reusable subschemas inside a GTS Type Schema SHOULD be placed under the Draft-07 canonical keyword `definitions`. Local JSON Pointer references such as `"$ref": "#/definitions/Foo"` are the recommended form. +**Dialect-agnostic.** GTS does not pin Type Schemas to a single JSON Schema draft. The dialect of any concrete GTS Type Schema is set by its `$schema` URI, and implementations MUST honour that dialect when validating or interpreting the schema body. The reference examples in this specification declare `$schema: http://json-schema.org/draft-07/schema#` because Draft-07 has the broadest tooling support and is the safest baseline for cross-vendor interoperability; however, Type Schemas that declare a later dialect — Draft 2019-09 (`https://json-schema.org/draft/2019-09/schema`) or Draft 2020-12 (`https://json-schema.org/draft/2020-12/schema`) — are equally valid GTS Type Schemas. Authors who wish to use post-Draft-07 keywords (`$defs`, `prefixItems`, `unevaluatedProperties`, `unevaluatedItems`, `$dynamicRef`/`$dynamicAnchor`, `dependentRequired`, `dependentSchemas`, …) MAY do so, provided the dialect declared in `$schema` admits those keywords and the GTS-specific rules (derivation compatibility per OP#12, trait validation per OP#13, modifiers per §9.11) are satisfied. + +This specification does **not** publish a dedicated GTS meta-schema or `$schema` URI; `x-gts-*` keywords are vendor extensions layered over whichever JSON Schema dialect a Type Schema declares. GTS Type Schemas are therefore **not** a [JSON Schema Dialect](https://json-schema.org/learn/glossary#dialect) in the formal sense — all GTS-specific constraints are enforced at the registry, not by a meta-schema. Whether a future revision will eventually publish a dedicated `$schema` URI and meta-schema (and thereby make GTS a Dialect formally) is an open question; this specification does not commit to that path. + +JSON Schema has no native concept of derivation or inheritance — its closest primitive, [`allOf`](https://json-schema.org/understanding-json-schema/reference/combining#allof), is a logical AND over [subschemas](https://json-schema.org/learn/glossary#subschema) at instance-validation time. In GTS, derivation is expressed by the **chained `$id`** (e.g., `gts://A~B~`); the schema body MAY use `allOf` with a `$ref` to the parent — which is convenient for avoiding duplication of the parent's fields and constraints in the derived schema — but is **not strictly required**. A derived schema that re-declares the parent's fields directly without `allOf` is admissible, provided it satisfies derivation compatibility (OP#12). See [`adr/0001-derivation-form.md`](adr/0001-derivation-form.md) for the full discussion. + +- Reusable subschemas inside a GTS Type Schema SHOULD be placed under the canonical container for the dialect declared by `$schema`: `definitions` for Draft-07, `$defs` for Draft 2019-09 and later. Local JSON Pointer references such as `"$ref": "#/definitions/Foo"` (Draft-07) or `"$ref": "#/$defs/Foo"` (Draft 2019-09+) are the recommended form. ### 11.1 Global rules: schema vs instance, normalization, and document categories diff --git a/adr/0001-derivation-form.md b/adr/0001-derivation-form.md new file mode 100644 index 0000000..5675559 --- /dev/null +++ b/adr/0001-derivation-form.md @@ -0,0 +1,255 @@ +# ADR-0001: Expressing derivation in GTS Type Schemas — GTS as a JSON Schema Extension (dialect-agnostic) + +- **Status:** Accepted +- **Date:** 2026-05-27 +- **Deciders:** GTS spec maintainers +- **Consulted:** — +- **Supersedes:** — +- **Superseded by:** — + +## Context and Problem Statement + +GTS expresses type derivation through **chained `$id`s**: `gts://A~` is a base type, `gts://A~B~` is a derived type whose immediate parent is `gts://A~`. The chain establishes the *fact* of derivation; operation **OP#12 ("Type Derivation Validation")** enforces the *semantic compatibility* contract — every valid instance of the derived schema must also be a valid instance of every ancestor in its chain. + +What the spec has so far been silent about is the structural side: **how should the JSON Schema document of a derived type be written?** + +### JSON Schema has no derivation concept + +This is the central observation. JSON Schema (Draft-07 and beyond) offers **composition** keywords — [`allOf`](https://json-schema.org/understanding-json-schema/reference/combining#allof), `oneOf`, `anyOf`, `not` — but **none** of them mean "parent type." `allOf` in particular is defined as a logical **AND** over [subschemas](https://json-schema.org/learn/glossary#subschema) at instance-validation time, nothing more: + +- It does not designate a "base." +- It carries no inheritance semantics. +- It imposes no "first item must be the parent" rule. +- It is symmetric: `allOf: [A, B]` and `allOf: [B, A]` produce the same effective constraints. + +Implementations and authors that today use `allOf` with a `$ref` to a parent type to express derivation are using `allOf` **by convention**, not by language design. + +### What the spec has not said + +Given the above, an author writing a derived `gts://A~B~` schema has no normative guidance on: + +- Whether they MUST use `allOf` with a `$ref` to the parent, or MAY skip `allOf` entirely and re-declare the parent's fields in the derived schema body. +- Where to place `properties` / `required` / `additionalProperties` / `x-gts-*` modifiers. +- Whether shapes like multi-item `allOf`, hybrid overlays (top-level overlay AND another inside `allOf`), or top-level `oneOf` / `anyOf` are admissible. + +In practice every reference implementation and every example under `examples/**/types/` happens to use the same convention — a 2-item `allOf` with a `$ref` to the parent plus an inline overlay — but this is convention, not contract. + +### Why this matters + +- **Authoring friction.** Newcomers ask "do I have to use `allOf`? where do I put `properties`?" and get no answer from the spec. +- **Cross-implementation variance.** Implementations may diverge on which shapes they accept (for example, a derived schema that lists parent fields directly without `allOf`). +- **Tooling assumptions.** Codegen, linters, and structural diffs assume a particular shape because there is nothing else to assume from. +- **`additionalProperties` interaction with `$ref`.** This is a well-known Draft-07 footgun and one of the reasons authoring guidance feels urgent. This ADR **acknowledges** the footgun but explicitly defers it to a separate discussion. + +### The framing this ADR adopts + +GTS positions itself as an **extension of JSON Schema**, dialect-agnostic. It **extends** JSON Schema with vendor keywords (`x-gts-traits-schema`, `x-gts-traits`, `x-gts-final`, `x-gts-abstract`, `x-gts-ref`, …) and **adds** registry-enforced semantic rules (derivation compatibility, finality, abstractness, traits). The dialect of any concrete GTS Type Schema is determined by its `$schema`; the spec's examples use Draft-07 as the baseline for maximum interoperability, but Draft 2019-09 and Draft 2020-12 are equally acceptable provided the dialect's keywords are used consistently. GTS does **not** forbid syntactically valid JSON Schema constructs from any of these dialects, and in particular it does **not** require `allOf` for derivation. + +This ADR does **not** make GTS a [JSON Schema Dialect](https://json-schema.org/learn/glossary#dialect) in the formal sense: GTS does not publish a dedicated `$schema` URI or meta-schema (each GTS Type Schema declares its dialect via the standard `$schema` URI of its choice — e.g. `http://json-schema.org/draft-07/schema#`, or a Draft 2019-09 / 2020-12 URI), and all GTS-specific constraints (`$id` shape, `x-gts-*` keyword shapes, derivation compatibility, completeness, etc.) are enforced at the registry rather than by a meta-schema. For the rest of this ADR the term *extension framing* is used. (Whether GTS will eventually become a formal Dialect is an open question — see §11.0 — and this ADR does not depend on either outcome.) + +## Decision Drivers + +- **Faithfulness to JSON Schema.** A user's existing JSON Schema, if it carries a valid GTS `$id` and `$schema`, should "just work" as a GTS Type Schema syntactically. +- **Derivation is GTS-level, not JSON-Schema-level.** Lineage lives in the `$id`. The schema body can express compatibility however its author chooses. +- **Author flexibility.** `allOf` is a convenient tool, not a mandate. +- **Separation of syntactic vs semantic validity.** Semantic compatibility (OP#12) is the contract that matters; syntax is incidental. + +## Considered Options + +Both options use the same running example. + +### Running example used in this section + +The base type `gts.x.example.user.v1~` is registered once and is identical across options: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.user.v1~", + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + } +} +``` + +The derived type we want to register is `gts.x.example.user.v1~vendor.premium_user.v1~` — a `premium_user` that extends `user` by adding a required `tier` field constrained to the enum `["gold", "platinum"]`. The concrete derived schema is shown under each option below, because the question of *what shapes are admissible* is exactly what the two options disagree on. + +Two instance payloads are referenced throughout: + +```json +// P-OK +{ "id": "u-1", "name": "Alice", "tier": "gold" } +``` + +```json +// P-BAD — has no `tier` +{ "id": "u-2", "name": "Bob" } +``` + +### Option 1 — Strict canonical form + +The spec mandates exactly one shape for derived schemas: `allOf` with the first (and possibly only) item being a `$ref` to the immediate parent, with `type`, `properties`, `required`, modifiers, and trait keywords placed at the **top level** of the derived schema. All other shapes are rejected at registration. + +*The single admissible form for `premium_user` under this option:* + +```jsonc +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.user.v1~vendor.premium_user.v1~", + "type": "object", + "allOf": [{ "$ref": "gts://gts.x.example.user.v1~" }], + "required": ["tier"], + "properties": { + "tier": { "type": "string", "enum": ["gold", "platinum"] } + } +} +``` + +P-OK validates; P-BAD fails (no `tier`). + +*An equivalent description that does NOT use `allOf` — parent fields listed directly:* + +```jsonc +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.user.v1~vendor.premium_user.v1~", + "type": "object", + "required": ["id", "name", "tier"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "tier": { "type": "string", "enum": ["gold", "platinum"] } + } +} +``` + +Effectively equivalent — every instance valid against this schema is valid against the parent and vice versa where the parent's fields are concerned. **Under Option 1, this form is rejected at registration** because the spec requires `allOf` + `$ref` to the parent. Authors who would naturally write a derived schema this way must rewrite it to use `allOf`, even though no semantic content changes. + +**Pros:** one shape for tools to recognise; a single inspection path for validators, linters, and codegen. + +**Cons:** conflicts with the extension framing — the spec ends up defining a *subset* of JSON Schema rather than a superset; outlaws otherwise-valid JSON Schemas; rejects derived schemas that simply choose not to use `allOf`; migrates / invalidates third-party schemas that happen to use a different shape; introduces a structural validator branch that every implementation must keep in sync. + +### Option 2 — GTS Type Schema as a JSON Schema Extension (dialect-agnostic) *(CHOSEN)* + +GTS does **not** restrict the syntactic form of a derived Type Schema. Any syntactically valid JSON Schema that carries a valid GTS `$id` and a `$schema` URI (Draft-07, Draft 2019-09, Draft 2020-12, or any future dialect that ships GTS support) is **syntactically** a valid GTS Type Schema. `allOf` is **not required** for derivation — derivation is established by the chained `$id`, and compatibility with the ancestor chain is checked semantically by OP#12. What matters is that the derived schema satisfies all **derivation compatibility rules** (described elsewhere in the spec). + +GTS still layers in: + +- **New keywords** — `x-gts-traits-schema`, `x-gts-traits`, `x-gts-final`, `x-gts-abstract`, `x-gts-ref`, and so on (see README §9.x and §11). +- **Semantic rules** that constrain *meaning*, not syntax — primarily derivation compatibility (OP#12) and the modifiers in §9.11. + +**Worked example — the same derived `premium_user~` written three different ways, all valid under Option 2.** + +*Variant 2a — `allOf` with `$ref` to parent, overlay at the top level:* + +```jsonc +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.user.v1~vendor.premium_user.v1~", + "type": "object", + "allOf": [{ "$ref": "gts://gts.x.example.user.v1~" }], + "required": ["tier"], + "properties": { + "tier": { "type": "string", "enum": ["gold", "platinum"] } + } +} +``` + +The derived schema delegates the parent's constraints to a `$ref`. Its own body adds `tier`. P-OK validates; P-BAD fails. + +*Variant 2b — `allOf` with `$ref` plus inline overlay (the current de-facto convention in examples):* + +```jsonc +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.user.v1~vendor.premium_user.v1~", + "allOf": [ + { "$ref": "gts://gts.x.example.user.v1~" }, + { + "type": "object", + "required": ["tier"], + "properties": { + "tier": { "type": "string", "enum": ["gold", "platinum"] } + } + } + ] +} +``` + +Same effective constraints. P-OK validates; P-BAD fails. + +*Variant 2c — **no `allOf` at all**; parent fields enumerated directly in the derived schema:* + +```jsonc +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.user.v1~vendor.premium_user.v1~", + "type": "object", + "required": ["id", "name", "tier"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" }, + "tier": { "type": "string", "enum": ["gold", "platinum"] } + } +} +``` + +The derived schema restates the parent's fields and adds its own. There is no `$ref` to the parent in the schema body. The lineage is still expressed by the chained `$id`, and OP#12 checks that every valid instance of this derived schema is also a valid instance of `gts.x.example.user.v1~` — which it is, since the derived schema includes `id` and `name` with the same constraints as the parent. + +P-OK validates; P-BAD fails. Syntactically valid and semantically compatible — admissible under Option 2. + +**Note on `additionalProperties`.** The Draft-07 footgun is acknowledged and explicitly deferred. Under Variant 2c (no `allOf`), `additionalProperties: false` at the top level "sees" all enumerated properties and behaves naturally — one practical reason an author might prefer 2c over 2a/2b. Under 2a/2b the same keyword interacts subtly with `$ref`. This ADR does not solve that. + +**Note on conventions.** Implementations and authors MAY still prefer a single shape (e.g., 2b) as a *convention* enforced by linters or by normalization on registration. This ADR does not forbid such conventions; it just refuses to put the restriction in the spec. + +## Decision Outcome + +Chosen: **Option 2 — GTS Type Schema as a JSON Schema Extension (dialect-agnostic).** + +Key normative consequences: + +- A syntactically valid JSON Schema document (in any supported dialect — Draft-07, Draft 2019-09, Draft 2020-12) carrying a valid GTS `$id` and `$schema` IS a syntactically valid GTS Type Schema. +- `allOf` is **not required** to express derivation. Derivation is established by the chained `$id`; compatibility is checked semantically by OP#12. +- Authors MAY choose between `allOf` + `$ref` to parent, `allOf` + inline overlay, no `allOf` at all (parent fields enumerated), or any other syntactically valid composition. +- A schema MAY still be **rejected at registration for semantic reasons** (e.g., violating derivation compatibility), but never purely for shape. + +### Implications + +- **README §11.0 ("Relationship to JSON Schema")** gains an explicit statement of the extension framing (dialect-agnostic, with Draft-07 as the example baseline) and the non-restriction principle (links to the JSON Schema glossary and to the `allOf` reference). +- **OP#12** scope is unchanged — it remains the semantic compatibility check. README wording that calls `allOf` "recommended" stays as a recommendation, not a requirement. +- **OP#6** is unchanged. +- **Reference implementations (gts-go, gts-rust)** MUST NOT reject derived schemas purely because they omit `allOf`, provided OP#12 semantic compatibility holds. +- **Conformance test suite** carries no "structural rejection" tests. Any existing tests that reject a shape purely because it lacks `allOf` (or uses a different composition) should be removed or rewritten as semantic-compatibility tests. +- **Examples and tests under `examples/**/types/`** are left as-is. The conventional 2-item `allOf` form remains a valid and recommended convention. +- **Backward compatibility:** non-breaking. + +## Pros and Cons of the Options + +### Option 1 — Strict canonical form + +- **+** One shape for tools to recognise; a single inspection path for validators, linters, codegen. +- **−** Conflicts with the extension framing — we'd be defining a *subset* of JSON Schema, not a superset. +- **−** Outlaws otherwise-valid JSON Schemas. +- **−** Rejects no-`allOf` derived schemas that are semantically compatible. +- **−** Invalidates third-party schemas that happen to use a different shape; requires a structural validator branch in every implementation. + +### Option 2 — GTS Type Schema as a JSON Schema Extension (dialect-agnostic) (chosen) + +- **+** Preserves "every valid JSON Schema is a valid GTS Type Schema," across whichever dialect the author picks. +- **+** Makes the role of `allOf` honest — optional convenience, not a mandate. +- **+** Tooling uniformity, if desired, is addressable by conventions / linters / normalization at the implementation layer. +- **+** Because Option 2 introduces no artificial restrictions and no new concepts beyond the existing JSON Schema vocabulary, the spec needs only one sentence to anchor the entire approach — *"a GTS Type Schema is a JSON Schema document extended with `x-gts-*` keywords and registry-enforced semantic rules"* — and everything else follows from that. **This makes the GTS spec itself simpler**, not just the schemas authored against it. +- **−** Tooling cannot rely on a single canonical inspection path at the spec level; uniformity has to be enforced (if at all) by conventions, linters, or registry-side normalization rather than by the spec. + +## More Information + +Cross-references inside this specification: README §3.2 (Inheritance), §9.11 (Modifiers), §11.0 (Relationship to JSON Schema), OP#12 (Type Derivation Validation). + +External references: + +- [`allOf` in JSON Schema](https://json-schema.org/understanding-json-schema/reference/combining#allof) +- [JSON Schema subschema (glossary)](https://json-schema.org/learn/glossary#subschema) +- [JSON Schema Dialect (glossary)](https://json-schema.org/learn/glossary#dialect) From 12ad2a3bb1253160ddea20e1efe15b2d0c8fa6ed Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Wed, 27 May 2026 13:15:46 +0300 Subject: [PATCH 2/7] docs(spec): adopt subschema framing for x-gts-traits-schema and add ADR-0002 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README §9.7: reframe x-gts-traits-schema as a JSON Schema subschema (object or boolean true/false), document chain aggregation via allOf, and link the new ADR. - adr/0002-x-gts-traits-schema.md: record the decision (Option 2A — subschema + implicit chain aggregation along the $id chain), including rejected alternatives, normative consequences, and cross-references to ADR-0001. Signed-off-by: Aviator 5 --- README.md | 25 ++- adr/0002-x-gts-traits-schema.md | 288 ++++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+), 7 deletions(-) create mode 100644 adr/0002-x-gts-traits-schema.md diff --git a/README.md b/README.md index ac8deea..080ae41 100644 --- a/README.md +++ b/README.md @@ -1335,7 +1335,7 @@ Implementation notes: ### 9.7 - GTS Type Schema Traits (`x-gts-traits-schema` / `x-gts-traits`) -**OP#13 - Schema Traits Validation**: Validate that `x-gts-traits` values in derived schemas conform to the `x-gts-traits-schema` defined in their base schemas. Verify that all trait properties are resolved (via direct value or `default`) and that trait values satisfy the trait schema constraints. Trait values set by an ancestor are immutable — descendants MUST NOT override them with a different value. Both `x-gts-traits-schema` and `x-gts-traits` are schema-only keywords and MUST NOT appear in instances. `x-gts-traits-schema` MUST have `"type": "object"`. Uses the same validation endpoints (`/validate-type-schema`, `/validate-entity`). +**OP#13 - Schema Traits Validation**: Validate that `x-gts-traits` values in derived schemas conform to the `x-gts-traits-schema` defined in their base schemas. Verify that all trait properties are resolved (via direct value or `default`) and that trait values satisfy the trait schema constraints. Trait values set by an ancestor are immutable — descendants MUST NOT override them with a different value. Both `x-gts-traits-schema` and `x-gts-traits` are schema-only keywords and MUST NOT appear in instances. `x-gts-traits-schema` MUST be a valid JSON Schema subschema (object, `true`, or `false`). Uses the same validation endpoints (`/validate-type-schema`, `/validate-entity`). A **schema trait** is a semantic annotation attached to a GTS Type Schema that describes **system behaviour** for processing instances of that type. Traits are not part of the object data model — they do not define instance properties. Instead, they configure cross-cutting concerns such as: @@ -1349,20 +1349,31 @@ Two JSON Schema annotation keywords are used together: | Keyword | JSON type | Purpose | Typical location | |---------|-----------|---------|------------------| -| **`x-gts-traits-schema`** | JSON Schema (object) | Defines the **shape** of the trait — property names, types, constraints, and `default` values | Base / ancestor schemas | +| **`x-gts-traits-schema`** | JSON Schema (object \| boolean) | Defines the **shape** of the trait — property names, types, constraints, and `default` values | Base / ancestor schemas | | **`x-gts-traits`** | Plain JSON object | Provides concrete **values** for the trait properties | Derived (leaf) schemas; may also appear alongside `x-gts-traits-schema` in the same schema | **Schema-only keywords:** Both `x-gts-traits-schema` and `x-gts-traits` are **schema annotation keywords** and MUST only appear in JSON Schema documents (documents with `$schema`). They MUST NOT appear in instance documents. Implementations MUST reject instances that contain these keywords. A single schema MAY contain both keywords. This is explicitly allowed and useful when a mid-level schema defines new trait properties (`x-gts-traits-schema`) while also resolving traits inherited from its parent (`x-gts-traits`). -**`x-gts-traits-schema`** MUST be a valid JSON Schema with `"type": "object"` at its top level. Implementations MUST reject trait schemas that declare a different type (e.g., `"type": "integer"`). It MAY be: +**`x-gts-traits-schema`** is a JSON Schema [subschema](https://json-schema.org/learn/glossary#subschema). By the JSON Schema definition, its value MAY therefore be: -- An **inline** schema object -- A **`$ref`** to a standalone, reusable trait schema -- A **composition** using `allOf`, `oneOf`, `anyOf`, etc. +- an **object subschema** — declares the trait shape in the usual way (`properties`, `required`, etc.); +- **`true`** — admits any trait values (the trivially-satisfied schema; traits remain permitted but unconstrained at this layer); +- **`false`** — prohibits traits entirely on this host, and on any descendant whose chain includes this layer (`false` is unsatisfiable, so the chain-aggregated effective trait-schema becomes unsatisfiable and `x-gts-traits` is rejected). -Standard JSON Schema `$ref` resolution rules apply — implementations MUST NOT invent a custom reference mechanism. +When `x-gts-traits-schema` is an object subschema, the **effective** trait-schema (after chain aggregation per §9.7.5) MUST constrain trait values to JSON objects. + +Because `x-gts-traits-schema` is an ordinary JSON Schema subschema, standard JSON Schema applies inside it without GTS reinventing anything: + +- `$ref` resolves per ordinary JSON Schema `$ref` rules (base URI resolution + JSON Pointer fragments). Implementations MUST NOT invent a custom reference mechanism. +- `allOf` / `oneOf` / `anyOf` / `not` carry their normal JSON Schema composition semantics — `allOf` is a logical AND over its subschemas at validation time, as for any other JSON Schema use. +- `properties` / `required` / `additionalProperties` / numeric and string constraints / `const` / `enum` / etc. behave as in any other JSON Schema. +- The trait shape MAY be declared **inline**, **referenced** from a standalone trait-schema registered as an ordinary GTS Type via `$ref`, or **composed** via `allOf` of inline parts and references. The choice is an authoring decision — inline keeps the trait surface private to the host and inheriting the host's ACL; the `$ref`-to-registered-type form is appropriate when the trait surface should be a separately governed artifact. + +**Inheritance along the host-type derivation chain happens at the registry level, not at the author level.** A descendant host type does NOT need to repeat the ancestor's `x-gts-traits-schema` inside its own — the registry composes all `x-gts-traits-schema` declarations encountered along the host's `$id` chain via JSON Schema `allOf` (see §9.7.5). A descendant MAY write an explicit `allOf` that includes a `$ref` to an ancestor's `x-gts-traits-schema`; doing so is redundant under chain aggregation but not invalid (consistent with the JSON Schema extension framing — see [`adr/0001-derivation-form.md`](adr/0001-derivation-form.md)). + +See [`adr/0002-x-gts-traits-schema.md`](adr/0002-x-gts-traits-schema.md) for the rationale behind the subschema framing and the chain-aggregation rule. **`x-gts-traits`** is a plain JSON object of concrete values. Constraint keywords like `const` belong in `x-gts-traits-schema` (the trait schema), not in `x-gts-traits` (the trait values). diff --git a/adr/0002-x-gts-traits-schema.md b/adr/0002-x-gts-traits-schema.md new file mode 100644 index 0000000..ad7847a --- /dev/null +++ b/adr/0002-x-gts-traits-schema.md @@ -0,0 +1,288 @@ +# ADR-0002: What `x-gts-traits-schema` is — a JSON Schema subschema with chain aggregation + +- **Status:** Accepted +- **Date:** 2026-05-27 +- **Deciders:** GTS spec maintainers +- **Consulted:** — +- **Supersedes:** — +- **Superseded by:** — + +## Context and Problem Statement + +A GTS host type — an ordinary GTS Type Schema describing instances of some type — may carry **trait metadata**: retention rules, indexing/routing directives, association links, and similar publisher-authored declarations that control system behaviour for instances of the type. Traits live alongside the type definition; they do not appear in instance payloads. Two keywords are used together: + +- `x-gts-traits-schema` — declares the **shape** of the trait metadata for this host; +- `x-gts-traits` — carries the concrete **values**. + +This ADR is about the **schema side** (`x-gts-traits-schema`). The value side — chain merge of `x-gts-traits` objects, immutability of inherited values, default resolution — is out of scope here. + +### The open question + +The spec must commit to **what kind of value** `x-gts-traits-schema` is. Two natural framings exist: + +- A **URI reference** to a separately-registered GTS Type whose schema describes the trait shape (trait-type as a first-class GTS Type). +- An ordinary [JSON Schema subschema](https://json-schema.org/learn/glossary#subschema) embedded inside the host type. + +### Why this matters + +Host types form derivation chains (`gts://A~B~`). The descendant's trait-schema must relate somehow to the ancestor's. The choice of value-space drives whether GTS needs a **parallel concept** in the registry — trait-type identity, trait-type derivation chain, trait-type lifecycle and access control — or whether ordinary JSON Schema composition is enough to carry the trait-schema along the host's derivation chain. + +### The framing inherited from ADR-0001 + +ADR-0001 commits GTS to being an **extension of JSON Schema** (dialect-agnostic; not a [JSON Schema Dialect](https://json-schema.org/learn/glossary#dialect) in the formal sense — GTS does not publish a dedicated `$schema` URI or meta-schema): extend JSON Schema with vendor keywords and registry-enforced semantic rules, do not invent parallel registry concepts when JSON Schema mechanisms suffice, and do not impose syntactic restrictions on otherwise-valid JSON Schemas. This ADR continues in that line. + +## Decision Drivers + +- **Spec simplicity.** Fewer GTS-specific concepts is strictly better, all else equal. +- **Consistency with ADR-0001.** Same extension framing — extend JSON Schema, don't subset or replace it. +- **Authoring ergonomics.** Inline traits, shared traits, and descendant additions should all be cheap to write and read. +- **Compatibility under derivation.** A descendant's effective trait-schema must be a structural narrowing of its ancestor's. This should fall out of the model, not require a separate compatibility rule. +- **Choice preserved.** Some trait surfaces want to be reusable, separately-governed artifacts; others want to stay private to a single host. The chosen option should allow both. + +## Considered Options + +Two options. Option 2 has two sub-variants (2A and 2B) which we evaluate inside Option 2. + +### Option 1 — Trait-type as a separately-registered GTS Type (URI value) + +`x-gts-traits-schema` is a **URI string** pointing at another registered GTS Type Schema that *is* the trait shape: + +```jsonc +// Separately registered trait-type +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.user_traits.v1~", + "type": "object", + "properties": { "retention": { "type": "string" } } +} + +// Base host type — Option 1 +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.user.v1~", + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + }, + "x-gts-traits-schema": "gts://gts.x.example.user_traits.v1~" +} + +// Derived host type — Option 1 +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.user.v1~vendor.premium_user.v1~", + "type": "object", + "allOf": [{ "$ref": "gts://gts.x.example.user.v1~" }], + "required": ["tier"], + "properties": { + "tier": { "type": "string", "enum": ["gold", "platinum"] } + }, + "x-gts-traits-schema": + "gts://gts.x.example.user_traits.v1~vendor.premium_user_traits.v1~" +} +``` + +For derivation, a separate **trait-type chain** must be expressed somehow — typically the descendant registers a derived trait-type (`...~vendor.premium_user_traits.v1~`) and points its host at it. GTS must then specify a rule for how host-type and trait-type chains relate (parallel derivation? URN-prefix matching? something else?). + +**Cons:** + +- **Always requires a separate registered type.** Overkill for the common case where a trait shape is fully local and has no reuse story. Pollutes the type namespace; vendors may not want every internal trait shape to be a publicly enumerable type. +- **Requires a parallel concept in the spec.** "Trait-type" and "trait-type derivation chain" become first-class registry concepts, with their own rules for how they relate to the host chain. ADR-0001 deliberately avoids this kind of layered complexity. +- **Registration ordering / lifecycle coupling.** The trait-type MUST be registered before any host that references it; cascade deletion and version bumps span two lifecycles. +- **Versioning friction.** Bumping a trait-type's version is a separate event from bumping the host's version; authors must coordinate the two. +- **Conceptual mismatch.** Trait-type instances make no sense — traits are metadata about a type, not instance shapes — yet a first-class GTS Type by definition has an instance space. The model invites misuse. +- **Mixing inline + shared is impossible.** A host that wants to combine an inline trait field with a shared trait shape cannot do so cleanly: it either picks the URI form (no inline) or duplicates the shared shape inline. +- **Removes the choice on ACLs.** A separately-registered trait-type has its own access-control surface, distinct from the host's. Sometimes that's exactly what a vendor wants (reusable trait-schema with its own governance); sometimes it's pure overhead (trait shape is private to one host and should inherit the host's ACL). Option 1 always forces the separate-ACL outcome; Option 2 preserves the choice (see "patterns within Option 2" below). + +**Pros:** + +- Reusable trait surfaces are explicitly first-class — but Option 2 covers this via `$ref` inside the subschema, so the pro is not exclusive. + +### Option 2 — `x-gts-traits-schema` is a JSON Schema subschema + +`x-gts-traits-schema` is an ordinary [JSON Schema subschema](https://json-schema.org/learn/glossary#subschema) embedded directly inside the host type. With this one framing the spec dispenses with a whole category of questions: yes, the value may contain `$ref`; yes, it may contain `allOf`; yes, it may declare `properties` / `required` / etc. — standard JSON Schema applies, because the value *is* a JSON Schema. + +**A subschema is an *object* OR a *boolean*** (per the JSON Schema glossary). Both forms are admissible for `x-gts-traits-schema`: + +- `"x-gts-traits-schema": { ... }` — an object subschema declares the trait shape in the usual way. +- `"x-gts-traits-schema": true` — the empty schema. **Any trait values pass**: traits are permitted but unconstrained at this layer. +- `"x-gts-traits-schema": false` — the unsatisfiable schema. **No traits are permitted** on this host and on any descendant whose chain includes this layer (`false` makes the chain-aggregated effective trait-schema unsatisfiable). Useful for a base type that wants to prohibit traits entirely, or for a leaf type that wants to opt out. + +Worked example, base host type: + +```jsonc +// Base host type — Option 2, object-form subschema +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.user.v1~", + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "string" }, + "name": { "type": "string" } + }, + "x-gts-traits-schema": { + "type": "object", + "properties": { "retention": { "type": "string" } } + } +} +``` + +#### Patterns within Option 2 + +Two patterns coexist under the same keyword: + +- **Inline.** The trait shape is declared inline inside the host. Trait shape is private; host's ACL applies; no separate registered artifact. (Used in the base host above.) +- **Standalone trait-schema referenced via `$ref`.** A vendor publishes a trait-schema as an ordinary GTS Type and references it from hosts: + + ```jsonc + // Standalone trait-schema, registered as an ordinary GTS Type + { "$id": "gts://gts.x.example.traits.user_meta.v1~", "type": "object", ... } + + // Host references it inside the subschema + "x-gts-traits-schema": { + "allOf": [{ "$ref": "gts://gts.x.example.traits.user_meta.v1~" }], + "properties": { "tenantScoped": { "type": "boolean" } } + } + ``` + +The standalone trait-schema is a normal registered GTS Type, governed by its own ACL — *when the vendor explicitly wants that*. Hosts that don't want a separately-governed artifact use the inline pattern. Both shapes are produced by the same keyword semantics; this is a pattern within Option 2, not a separate option. + +Two sub-variants on **how trait-schemas compose under host-type derivation**: + +#### Option 2A — Implicit aggregation along the `$id` chain *(chosen)* + +The registry's *effective* `x-gts-traits-schema` for a host type T is the JSON Schema `allOf` composition of all `x-gts-traits-schema` declarations encountered along T's host-type derivation chain, root → leaf. The descendant author writes only the **delta** (new fields, narrowed constraints, additional `$ref`s) in their own `x-gts-traits-schema`; the inheritance is performed by the registry. + +Worked example — descendant adds `supportLevel`: + +```jsonc +// Derived host type — Option 2A +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.user.v1~vendor.premium_user.v1~", + "type": "object", + "allOf": [{ "$ref": "gts://gts.x.example.user.v1~" }], + "required": ["tier"], + "properties": { + "tier": { "type": "string", "enum": ["gold", "platinum"] } + }, + "x-gts-traits-schema": { + "type": "object", + "properties": { + "supportLevel": { "type": "string", "enum": ["standard", "priority"] } + } + } +} +``` + +The author does not repeat the ancestor's `retention` property inside `x-gts-traits-schema`. The registry composes via `allOf` along the `$id` chain, so the effective trait-schema for `premium_user.v1~` is equivalent to: + +```jsonc +{ + "allOf": [ + { "type": "object", "properties": { "retention": { "type": "string" } } }, + { "type": "object", "properties": { "supportLevel": { "type": "string", "enum": ["standard","priority"] } } } + ] +} +``` + +By construction, any value satisfying the effective trait-schema also satisfies every ancestor's — so **trait-schema compatibility under derivation is automatic and structural**, no separate "narrowing" check needed. + +#### Option 2B — Explicit composition by the author + +`x-gts-traits-schema` is still a JSON Schema subschema, but the spec does NOT pre-compose declarations along the host chain. If the descendant wants the ancestor's trait fields, the author writes the composition by hand inside their own `x-gts-traits-schema`: + +```jsonc +// Derived host type — Option 2B +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.user.v1~vendor.premium_user.v1~", + "type": "object", + "allOf": [{ "$ref": "gts://gts.x.example.user.v1~" }], + "required": ["tier"], + "properties": { + "tier": { "type": "string", "enum": ["gold", "platinum"] } + }, + "x-gts-traits-schema": { + "allOf": [ + { "$ref": "gts://gts.x.example.user.v1~#/x-gts-traits-schema" }, + { + "type": "object", + "properties": { + "supportLevel": { "type": "string", "enum": ["standard", "priority"] } + } + } + ] + } +} +``` + +#### 2A vs 2B trade-off + +- **2A pros.** Less boilerplate; lower chance of accidentally dropping ancestor traits (forgetting `allOf` in 2B silently strips inheritance); symmetric with the extension framing in ADR-0001. +- **2A cons.** Requires one explicit GTS rule — "the registry composes `x-gts-traits-schema` along the `$id` chain via `allOf`." This is a genuine GTS-specific rule (JSON Schema does not define cross-`$ref` annotation aggregation in any dialect). One sentence in the spec. +- **2B pros.** No GTS-specific aggregation rule — the trait subschema is a plain JSON Schema subschema, full stop. Symmetric with how a derived host body uses explicit `allOf` + `$ref` for parent fields. +- **2B cons.** Boilerplate; the failure mode (forgetting `allOf`) is a *silent drop* of inherited traits rather than a visible error; awkward `$ref` syntax for fragments into another schema document (`gts://...~#/x-gts-traits-schema`). + +## Decision Outcome + +Chosen: **Option 2A — `x-gts-traits-schema` is a JSON Schema subschema (object OR boolean); the registry composes declarations along the `$id` chain via `allOf`.** + +Normative consequences: + +- `x-gts-traits-schema` is an ordinary JSON Schema subschema. Its value MAY be: + - a JSON object — declares the trait shape in the usual way; + - `true` — any trait values pass (no constraint); + - `false` — no traits permitted on this host (and unsatisfiable in chain aggregation, so any descendant chain containing `false` allows no traits either). +- The *effective* `x-gts-traits-schema` of a host type T is the `allOf` composition of all `x-gts-traits-schema` declarations encountered along T's `$id` chain, root → leaf. The author writes only the delta; the registry aggregates. +- Compatibility of a descendant's trait-schema with its ancestor's is **automatic and structural** — every value satisfying the effective trait-schema also satisfies the ancestor's declaration by construction. +- A descendant MAY write its own `x-gts-traits-schema` as an explicit `allOf` that includes a `$ref` to an ancestor's `x-gts-traits-schema`. Doing so is **redundant under 2A** (the registry already aggregates) but **not invalid** — outlawing it would violate the ADR-0001 principle "any syntactically valid JSON Schema is a syntactically valid GTS Type Schema." +- A vendor MAY publish a standalone trait-schema as an ordinary GTS Type and reference it from hosts via `$ref` inside `x-gts-traits-schema`. This is a pattern within Option 2, not a separate option. It is the right choice when the vendor wants the trait surface to have its own governance / lifecycle / ACL; the inline form is the right choice otherwise. + +### Implications + +- **README §9.7** is updated to reflect this ADR — `x-gts-traits-schema` is named as a JSON Schema subschema (with glossary link), the boolean forms are made explicit, and the chain-aggregation rule is stated where readers expect to find it. +- **OP#13 (Schema Traits Validation)** is unchanged in scope — it remains the operation that validates effective trait-schemas and effective trait values. +- **`additionalProperties` inside a trait-schema.** The Draft-07 `$ref`+siblings / `$ref`+`additionalProperties: false` footgun (resolved in Draft 2019-09+ via `unevaluatedProperties`) applies inside `x-gts-traits-schema` exactly as it applies inside the host body when the dialect is Draft-07. Acknowledged and explicitly deferred to a separate discussion (same disposition as in ADR-0001). +- **Value side (`x-gts-traits`).** Out of scope of this ADR; covered in §9.7.3–§9.7.5. +- **Reference implementations (gts-go, gts-rust)** must support the boolean subschema form for `x-gts-traits-schema` and the chain-aggregation behaviour described above. +- **Backward compatibility.** The keyword's value space gains the boolean form; existing object-form schemas remain valid. + +## Pros and Cons of the Options + +### Option 1 — Trait-type as separate registered GTS Type + +- **+** Reusable trait surfaces are first-class (not exclusive — Option 2 covers this via `$ref`). +- **−** Forces a parallel registry concept; lifecycle / versioning / ACL coupling; no inline option. +- **−** Removes the inline-vs-registered choice that vendors actually need. +- **−** Conceptual mismatch — trait-types have no meaningful instance space, but first-class GTS Types by definition have one. +- **−** Mixing inline + shared is impossible without duplication. + +### Option 2A — Subschema + implicit chain aggregation (chosen) + +- **+** One sentence of GTS-specific rule; everything else falls out of standard JSON Schema. +- **+** Symmetric with the ADR-0001 extension framing. +- **+** Trait-schema compatibility under derivation is automatic and structural. +- **+** Admits boolean subschema forms (`true` / `false`) naturally — useful for "no constraint" and "no traits allowed" cases. +- **+** Preserves the inline-vs-registered choice via the standalone-trait-schema-by-`$ref` pattern. +- **−** Requires authors to know that chain aggregation happens at the registry level (not visible in the schema text itself). + +### Option 2B — Subschema + explicit composition by author + +- **+** Zero GTS-specific aggregation rule; trait subschema is "just" a JSON Schema subschema. +- **−** Silent-failure mode — forgetting `allOf` strips inheritance with no visible error. +- **−** Awkward `$ref` syntax for cross-document fragment references into another schema. +- **−** Per-descendant boilerplate that scales with chain depth. + +## More Information + +Cross-references inside this specification: §9.7 (`x-gts-traits-schema` / `x-gts-traits`), §11.0 (Relationship to JSON Schema), §3.2 (GTS Types Inheritance), OP#13 (Schema Traits Validation). Related ADR: [`adr/0001-derivation-form.md`](0001-derivation-form.md) — the extension framing this ADR inherits. + +External references: + +- [JSON Schema subschema (glossary)](https://json-schema.org/learn/glossary#subschema) +- [JSON Schema Dialect (glossary)](https://json-schema.org/learn/glossary#dialect) +- [`allOf` in JSON Schema](https://json-schema.org/understanding-json-schema/reference/combining#allof) From 5bdc3ed7305468db3f8daf233c043f9478eed5c6 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Wed, 27 May 2026 15:23:49 +0300 Subject: [PATCH 3/7] docs(spec): key trait completeness on x-gts-abstract and add ADR-0003 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite §9.7.5 'Validation' to require completeness only for non-abstract types, with materialization via effective trait-schema defaults before validating against required. - Rewrite §9.11.4 to express the rule via the x-gts-abstract modifier; final types fall out as a corollary (non-abstract by definition). - Refine OP#13 wording in §9.7 to match: completeness applies to non-abstract types and operates on the materialized effective traits object. - Add ADR-0003 documenting the decision to enforce trait completeness at type registration (Option 3), keyed on x-gts-abstract rather than the registry-state-dependent 'leaf' notion. Signed-off-by: Aviator 5 --- README.md | 11 +- adr/0003-x-gts-traits-completeness.md | 294 ++++++++++++++++++++++++++ 2 files changed, 300 insertions(+), 5 deletions(-) create mode 100644 adr/0003-x-gts-traits-completeness.md diff --git a/README.md b/README.md index 080ae41..ab81fc4 100644 --- a/README.md +++ b/README.md @@ -1335,7 +1335,7 @@ Implementation notes: ### 9.7 - GTS Type Schema Traits (`x-gts-traits-schema` / `x-gts-traits`) -**OP#13 - Schema Traits Validation**: Validate that `x-gts-traits` values in derived schemas conform to the `x-gts-traits-schema` defined in their base schemas. Verify that all trait properties are resolved (via direct value or `default`) and that trait values satisfy the trait schema constraints. Trait values set by an ancestor are immutable — descendants MUST NOT override them with a different value. Both `x-gts-traits-schema` and `x-gts-traits` are schema-only keywords and MUST NOT appear in instances. `x-gts-traits-schema` MUST be a valid JSON Schema subschema (object, `true`, or `false`). Uses the same validation endpoints (`/validate-type-schema`, `/validate-entity`). +**OP#13 - Schema Traits Validation**: Validate that `x-gts-traits` values in derived schemas conform to the `x-gts-traits-schema` defined in their base schemas. Verify that, for non-abstract types, all required trait properties in the effective trait-schema are resolved (via explicit value in the chain-merged `x-gts-traits` or via `default` in the effective trait-schema), and that trait values satisfy the effective trait-schema's other constraints. Trait values set by an ancestor are immutable — descendants MUST NOT override them with a different value. Both `x-gts-traits-schema` and `x-gts-traits` are schema-only keywords and MUST NOT appear in instances. `x-gts-traits-schema` MUST be a valid JSON Schema subschema (object, `true`, or `false`). Uses the same validation endpoints (`/validate-type-schema`, `/validate-entity`). A **schema trait** is a semantic annotation attached to a GTS Type Schema that describes **system behaviour** for processing instances of that type. Traits are not part of the object data model — they do not define instance properties. Instead, they configure cross-cutting concerns such as: @@ -1511,9 +1511,8 @@ Given an inheritance chain `S₀ → S₁ → … → Sₙ`: - Defaults declared in the effective trait schema SHOULD be used as normal JSON Schema defaults to produce a complete effective traits object. - **Validation** - - The registry MUST validate the effective traits object against the effective trait schema using standard JSON Schema validation. + - **Completeness check** (registration-time): For types whose `x-gts-abstract` is not `true`, the registry MUST verify that the *materialized* effective traits object validates against the effective trait-schema using standard JSON Schema validation. "Materialized" means: defaults declared in the effective trait-schema for properties not present in the chain-merged effective traits object are substituted in before validation. If validation fails — in particular, if a `required` property of the effective trait-schema has no chain-assigned value and no default — registration MUST fail. For types with `x-gts-abstract: true`, this completeness check is skipped; descendants are expected to close any unresolved required traits. See [`adr/0003-x-gts-traits-completeness.md`](adr/0003-x-gts-traits-completeness.md) for the rationale. - If the effective trait schema cannot be satisfied (e.g., contradictory constraints introduced across the chain), schema validation MUST fail. - - If a trait is required by the effective trait schema (i.e., not covered by a default) but is not provided by any `x-gts-traits` in the chain, schema validation MUST fail for concrete (leaf) schemas. - If a descendant attempts to override a trait value already set by an ancestor with a different value, schema validation MUST fail. **Example — immutable trait override (failure):** @@ -1622,9 +1621,11 @@ When a schema declares `"x-gts-abstract": true`: #### 9.11.4 Interaction with `x-gts-traits` -- **Abstract types with traits**: An abstract base type MAY declare `x-gts-traits-schema`. Since abstract types cannot have direct instances, trait values (`x-gts-traits`) do not need to be fully resolved on the abstract type itself. Trait resolution completeness is only enforced on concrete (leaf) schemas (this is already the existing behavior from section 9.7.5). +- **Completeness keyed on `x-gts-abstract`**: A type whose `x-gts-abstract` is not `true` MUST satisfy trait completeness at registration (see §9.7.5). A type with `x-gts-abstract: true` is exempt — abstract types may have unresolved required traits; descendants are expected to close them. See [`adr/0003-x-gts-traits-completeness.md`](adr/0003-x-gts-traits-completeness.md). -- **Final types with traits**: A final type MAY declare `x-gts-traits` values. Since no derived types can exist, all trait values MUST be fully resolved on the final type itself. If the effective trait schema has required properties without defaults and the final type does not provide them via `x-gts-traits`, validation MUST fail. +- **Final types follow the non-abstract rule**: A type with `x-gts-final: true` is non-abstract by definition (abstract+final is rejected per §9.11.1) and therefore subject to the completeness check. Because no further descendants are permitted, completeness must be satisfied by the final type itself — by chain-inherited values, locally declared `x-gts-traits`, or `default`s in the effective trait-schema. + +- **Abstract types may declare `x-gts-traits-schema`**: Doing so contributes to the effective trait-schema of descendants; the abstract type itself is not required to provide values. #### 9.11.5 Registration enforcement diff --git a/adr/0003-x-gts-traits-completeness.md b/adr/0003-x-gts-traits-completeness.md new file mode 100644 index 0000000..6cfcf9e --- /dev/null +++ b/adr/0003-x-gts-traits-completeness.md @@ -0,0 +1,294 @@ +# ADR-0003: `x-gts-traits` completeness — when required traits must be resolved + +- **Status:** Accepted +- **Date:** 2026-05-27 +- **Deciders:** GTS spec maintainers +- **Consulted:** — +- **Supersedes:** — +- **Superseded by:** — + +## Context and Problem Statement + +GTS types carry trait values via `x-gts-traits` against a trait-schema declared in `x-gts-traits-schema` (per ADR-0002). The trait-schema may declare some properties as `required`. The spec must decide what happens to those required properties when they are not resolved — neither explicitly assigned by `x-gts-traits` along the type's `$id` chain, nor covered by a `default` in the effective trait-schema. + +### How completeness arises + +A GTS type's `x-gts-traits` provides concrete values for the trait properties declared by `x-gts-traits-schema`. Trait-schemas (per ADR-0002) compose along the `$id` chain via `allOf` to produce an *effective trait-schema*. Trait values compose along the same chain to produce an *effective traits object*. When the effective trait-schema marks a property as `required`, the effective traits object must satisfy that requirement somehow — otherwise the type's trait surface is, in JSON Schema terms, invalid. + +### What the spec must decide + +- Whether GTS enforces completeness at all (or leaves it to authors / runtime). +- If yes, at what moment the check fires. +- For which types the check fires. +- What counts as "resolved" — does `default` in the effective trait-schema satisfy `required`, or only explicit values? + +### Why this matters + +Two scenarios bite if the spec stays silent. (a) A type that ships values for some traits but leaves a required one unset registers without complaint; downstream consumers see "missing retention" / "missing topic-ref" at runtime, with no visible error at registration. (b) An abstract base type that declares a required trait field with no default has no way to remain abstract — it can never satisfy its own required surface, and the spec has no carve-out for "incomplete by design." + +### Worked example to anchor the question + +Consider a base event type that declares a required `retention` trait with no default: + +```jsonc +// Base event type +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~", + "type": "object", + "required": ["id", "occurredAt"], + "properties": { + "id": { "type": "string" }, + "occurredAt": { "type": "string", "format": "date-time" } + }, + "x-gts-traits-schema": { + "type": "object", + "required": ["retention"], + "properties": { + "retention": { "type": "string" } + } + } +} +``` + +A vendor registers a derived event type without supplying a `retention` value: + +```jsonc +// Derived event type — no x-gts-traits at all +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~vendor.order_placed.v1~", + "type": "object", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "required": ["orderId"], + "properties": { + "orderId": { "type": "string" } + } +} +``` + +The effective trait-schema for `order_placed.v1~` (per ADR-0002) requires `retention`. The effective traits object is empty. The required field is unresolved. **Should this registration succeed or fail?** The answer depends on: + +- whether GTS enforces completeness at all (Option 1 says "no rule"); +- if so, whether the check fires now or later (Option 2 vs Option 3); +- whether the derived type's modifier matters (a non-abstract derived type ≠ an abstract derived type). + +Three variants of the same example clarify what's at stake: + +- *Variant A — base declares a default:* if `x-gts-traits-schema.properties.retention` has `"default": "P30D"`, the required field is satisfied by the default. The derived type can register without writing `x-gts-traits`. +- *Variant B — derived type supplies the value:* the derived adds `"x-gts-traits": { "retention": "P90D" }`. Required is resolved. +- *Variant C — derived type marks itself abstract:* `"x-gts-abstract": true`. The type declares "I am intentionally incomplete; my descendants close `retention`." Whether to accept this depends on the option chosen. + +### The framing this ADR inherits + +ADR-0001 commits GTS to being an extension of JSON Schema (dialect-agnostic); ADR-0002 says `x-gts-traits-schema` is an ordinary JSON Schema subschema with chain aggregation at the registry. The completeness rule follows the same philosophy: use standard JSON Schema mechanisms where possible, add a minimum of GTS-specific policy on top. + +## Decision Drivers + +- **Fail fast.** Errors should surface at registration time, not at instance creation or at runtime, when affordable. +- **Respect abstract intent.** Abstract types are explicitly "incomplete waiting for descendants"; the rule must accommodate this. +- **No registry-state-dependent rules.** Whether a type is currently a "leaf" depends on which descendants happen to be registered. Tying the completeness rule to that property creates post-hoc invariants — registering a descendant could retroactively change whether the ancestor was "supposed to be" complete. Avoid. +- **Ergonomics for the common case.** A required trait with a sensible `default` should not force the author to write a redundant `x-gts-traits` entry just to "explicitly satisfy" required. + +## Considered Options + +Three top-level options on **when (or whether) to enforce completeness**. + +### Option 1 — No spec-level enforcement (author's responsibility) + +The spec describes how `x-gts-traits` and `x-gts-traits-schema` work, but says nothing normative about required-trait satisfaction. If a non-abstract type registers with unresolved required traits, that's the author's bug, surfaced (if at all) by downstream tooling or by runtime failures. + +- **+** Smallest spec footprint. No new operation, no new failure mode. +- **−** Defers a real correctness problem to runtime. A type that *looks* well-formed at registration can break downstream consumers at any later point. No clear contract for what "valid trait surface" means. +- **−** The `required` keyword in `x-gts-traits-schema` becomes effectively informational — it tells consumers "this matters" but the registry doesn't check anything. Encourages divergence between implementations. + +### Option 2 — Validate at instance creation time + +Type registration is permissive: any non-abstract type may register with an incomplete trait surface. When an instance is registered against such a type, the registry computes the type's effective traits and rejects the instance registration if required traits are unresolved. + +- **+** A type-author can publish work-in-progress non-abstract types without immediately providing values for every required trait; descendants or the same type's later updates can close gaps before any instance ever exists. +- **+** No new failure mode at type registration. +- **−** Late failure — the broken trait surface goes undetected from type registration until the first instance is registered, which could be much later, against a different operator, on a different system. Hard to diagnose. +- **−** Asymmetric with type-side validation already enforced at registration (structure, derivation compatibility per OP#12, finality guard per §9.11.2, trait-schema satisfiability per ADR-0002). +- **−** An incomplete non-abstract type that someone never instantiates sits in the registry indefinitely as a latent defect. The `x-gts-abstract` modifier already serves the "intentionally incomplete" use case — there is no separate need for "non-abstract but also incomplete". + +**Concrete example.** Reusing the base event type from the worked example above (required `retention` trait, no default): + +```jsonc +// Non-abstract derived type — no x-gts-traits, no x-gts-abstract. +// Under Option 2: registration SUCCEEDS (completeness is not checked at type time). +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~vendor.order_placed.v1~", + "type": "object", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "required": ["orderId"], + "properties": { "orderId": { "type": "string" } } +} +// → Registration accepted. Type sits in the registry with an unresolved +// required trait. + +// First instance of this type, registered some time later. +{ + "id": "evt-001", + "occurredAt": "2026-05-27T12:00:00Z", + "orderId": "ord-7" +} +// → Instance registration FAILS: required trait `retention` unresolved on +// gts://gts.x.example.event.v1~vendor.order_placed.v1~. +// The defect surfaces at the first instance — possibly long after the +// type was registered, in a different system or by a different operator. +``` + +### Option 3 — Validate at type registration (fail fast) *(chosen)* + +At type registration, the registry computes the effective trait-schema and effective traits object for the new type. For **non-abstract** types, the registry MUST verify that every required property in the effective trait-schema is resolved — either by an explicit value in the chain-merged `x-gts-traits`, or by a `default` declared in the effective trait-schema. For **abstract** types, this check is skipped — abstract types are by definition "incomplete waiting for descendants." + +- **+** Fail fast at the obvious correctness boundary. By the time a non-abstract type is in the registry, any instance against it is guaranteed to find a complete trait surface — no separate check at instance time needed. +- **+** `x-gts-abstract` already exists for the "intentionally incomplete" case; this option uses it as the natural escape hatch instead of inventing a separate carve-out. +- **+** No notion of "leaf" required. Whether the type has descendants now or later is irrelevant — the rule is keyed on `x-gts-abstract`, which is a property of the type itself. +- **+** Symmetric with other registration-time validations (structure, OP#12 derivation compatibility, OP#13 trait-schema satisfiability). +- **−** Authors of mid-level types who want to publish "non-abstract scaffolding with required traits TBD" must either provide stub `default` values, provide stub `x-gts-traits` values, or mark the type abstract. In practice this is the right pressure — incomplete-but-instantiable is a bug, not a feature. + +**Concrete example.** Same base event type as above (required `retention` trait, no default): + +```jsonc +// Non-abstract derived type with no x-gts-traits. +// Under Option 3: registration FAILS at type time. +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~vendor.order_placed.v1~", + "type": "object", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "required": ["orderId"], + "properties": { "orderId": { "type": "string" } } +} +// → Error: required trait `retention` unresolved on non-abstract type +// gts://gts.x.example.event.v1~vendor.order_placed.v1~. +``` + +Three ways to make the registration succeed: + +```jsonc +// (a) Supply the value directly on the derived type via x-gts-traits. +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~vendor.order_placed.v1~", + "type": "object", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { "retention": "P90D" }, + "required": ["orderId"], + "properties": { "orderId": { "type": "string" } } +} +// → OK: effective traits object = { retention: "P90D" }, satisfies required. +``` + +```jsonc +// (b) Declare a default in the base trait-schema (ripples to all descendants). +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~", + "type": "object", + "required": ["id", "occurredAt"], + "properties": { + "id": { "type": "string" }, + "occurredAt": { "type": "string", "format": "date-time" } + }, + "x-gts-traits-schema": { + "type": "object", + "required": ["retention"], + "properties": { + "retention": { "type": "string", "default": "P30D" } + } + } +} +// → All descendants that don't override `retention` materialize it to "P30D" +// and pass the completeness check without writing x-gts-traits themselves. +``` + +```jsonc +// (c) Mark the derived type abstract — it opts out of the completeness check. +// Concrete descendants will still have to resolve `retention`. +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~vendor.order_placed.v1~", + "type": "object", + "x-gts-abstract": true, + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "required": ["orderId"], + "properties": { "orderId": { "type": "string" } } +} +// → OK: completeness check skipped for abstract types. +``` + +## Decision Outcome + +Chosen: **Option 3 — validate at type registration; non-abstract types MUST be complete.** + +### Definition of "complete" + +A GTS type T is **complete** iff the materialized effective traits object of T validates against the effective trait-schema of T using standard JSON Schema validation, where "materialized" means: for every property in the effective trait-schema that has a `default` and is not present in the chain-merged effective traits object, the `default` value is substituted in. + +### Qualifier + +The completeness check applies if and only if the type's `x-gts-abstract` is not `true` (i.e., the type is non-abstract). Final types (`x-gts-final: true`) are not a special case — they are non-abstract types, and the same rule applies. The "leaf" notion (registry-state-dependent absence of descendants) plays no role. + +### Algorithm + +At the registration of type T: + +1. Compute *effective trait-schema* = JSON Schema `allOf` composition of all `x-gts-traits-schema` declarations along T's `$id` chain (per ADR-0002). +2. Compute *effective traits object* = chain-merged `x-gts-traits` along T's `$id` chain (subject to the immutable-once-set rule for inherited values). +3. *Materialize* the effective traits object: for every property declared in the effective trait-schema with a `default` and no value in the merged object, substitute the default value. +4. If `T.x-gts-abstract` is `true`: skip the completeness check (other validations on T still run normally). +5. Otherwise: validate the materialized effective traits object against the effective trait-schema using standard JSON Schema validation. If validation fails (including `required` properties absent after materialization), registration MUST fail with an error citing the unresolved required properties. + +### Edge cases this rule covers correctly + +- **No traits anywhere in chain.** Effective trait-schema is empty / `true` / has no `required`. Materialized object validates trivially. ✓ +- **`x-gts-traits-schema: false` somewhere in the chain.** This is the strong "no traits permitted" declaration. Derivation compatibility (per ADR-0002 / OP#12) requires the effective trait-schema of every descendant to be `allOf` of all `x-gts-traits-schema` along its chain, which includes `false`. Because `false` is the unsatisfiable schema and `allOf(false, anything) ≡ false`, the effective trait-schema of every descendant in the subtree is also `false` — descendants cannot "extend" or "override" it; they inherit the unsatisfiability. The consequence: **on the entire subtree rooted at the `false` declaration, traits are effectively banned**: + - A non-abstract type in the subtree fails registration the moment it tries to provide any `x-gts-traits` value (or has a required to satisfy) — the only `x-gts-traits` that can validate against `false` is "no traits at all" together with no `required`, which `false` doesn't have. + - In practice, the only way to register a non-abstract type under a `false` is to provide no `x-gts-traits` at all *and* have no required traits — which is precisely the "no traits, period" semantics the author signaled with `false`. + - An abstract type in the subtree may exist (skips the check) but offers no useful path to descendants either, because every descendant will still inherit `false`. +- **Abstract base with required-no-default, non-abstract descendant supplies value.** Base registration skips the check (abstract). Descendant registration runs the check on its effective state; the descendant's `x-gts-traits` value satisfies the required field. ✓ +- **Non-abstract type with required-no-default and no explicit value.** Registration fails. Author resolves by (a) providing `x-gts-traits` value, (b) declaring a `default` in the schema, or (c) marking the type abstract. +- **Final non-abstract type.** Same rule as any non-abstract — must be complete. No special bullet needed. + +## Implications + +- **OP#13 (Schema Traits Validation)** includes this rule; the operation explicitly conditions the completeness step on `x-gts-abstract != true`. +- **§9.7.5** carries the normative wording of the completeness check in the "Validation" bullet block, expressed in terms of "non-abstract types." +- **§9.11.4 (Interaction with `x-gts-traits`)** states the same rule via the modifier lens — keyed on `x-gts-abstract`, with the final case shown as a corollary. +- **`x-gts-traits` value validation** against trait-schema property constraints (independent of `required`) is unchanged; this ADR only adds the conditional `required` resolution check. +- **Reference implementations (gts-go, gts-rust)** must implement the materialization step and the conditional completeness check at type registration. +- **Backward compatibility.** The rule applies to any new registration. Existing registered types are evaluated against this rule on their next registration / re-registration; production registries SHOULD treat already-registered types as grandfathered until the next write. + +## Pros and Cons of the Options + +### Option 1 — No spec-level enforcement + +- **+** Smallest spec footprint. +- **−** Defers a real correctness problem to runtime; `required` becomes informational only. + +### Option 2 — Validate at instance creation time + +- **+** Permits "work-in-progress" non-abstract types. +- **−** Late failure; asymmetric with other registration-time checks. +- **−** `x-gts-abstract` already covers the legitimate "intentionally incomplete" case so the extra permissiveness has no clear use. + +### Option 3 — Validate at type registration (chosen) + +- **+** Fail fast at the natural boundary; uses `x-gts-abstract` as the natural escape hatch. +- **+** "Leaf" notion drops out; rule is keyed on a property of the type itself. +- **+** Symmetric with other registration-time validations (OP#12, OP#13 trait-schema satisfiability, finality guard). +- **+** Defaults remain ergonomic — required-with-default does not force a redundant `x-gts-traits` entry. +- **−** Mid-level non-abstract types that genuinely want to leave a required trait open must mark themselves abstract (or supply stub defaults). In practice this is the right pressure. + +## More Information + +Cross-references inside this specification: §9.7 (`x-gts-traits-schema` / `x-gts-traits`), §9.11 (`x-gts-final` / `x-gts-abstract`), OP#13 (Schema Traits Validation). Related ADRs: [`adr/0001-derivation-form.md`](0001-derivation-form.md) (the extension framing), [`adr/0002-x-gts-traits-schema.md`](0002-x-gts-traits-schema.md) (effective trait-schema via chain aggregation — the input to the completeness check). + +External references: + +- [`required` in JSON Schema](https://json-schema.org/understanding-json-schema/reference/object#required) +- [`default` in JSON Schema](https://json-schema.org/understanding-json-schema/reference/annotations) From d9a74889af9bae7318268cca6e2f99dfb4e6b237 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Wed, 27 May 2026 16:28:48 +0300 Subject: [PATCH 4/7] docs(spec): adopt JSON Merge Patch (RFC 7396) for x-gts-traits and add ADR-0004 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace prior trait-value merge rule in README §9.7 with chain-applied JSON Merge Patch (RFC 7396) along the $id chain: descendant-last-wins at leaves, nested objects merge recursively, arrays replace wholesale, `null` deletes the key. - Make standard JSON Schema `const` in x-gts-traits-schema the publisher's lock mechanism (no GTS-specific immutability rule); a descendant that attempts to override a const-locked value fails ordinary JSON Schema validation against the effective trait-schema. - Document the principal use of `null` as a fallback to the trait-schema's `default` via ADR-0003 materialization, including the failure mode when the deleted key is required-without-default. - Update OP#13 description to drop the obsolete immutability sentence and point at `const` as the lock mechanism. - Add ADR-0004 with drivers, alternatives (no-merge, shallow last-wins, shallow immutable-once-set, RFC 7396, per-property keyword), decision, worked examples (override / nested merge / const lock / null-to-default fallback), edge cases, and conformance expectations. Signed-off-by: Aviator 5 --- README.md | 34 +- adr/0004-x-gts-traits-merge-strategy.md | 525 ++++++++++++++++++++++++ 2 files changed, 543 insertions(+), 16 deletions(-) create mode 100644 adr/0004-x-gts-traits-merge-strategy.md diff --git a/README.md b/README.md index ab81fc4..7f52f87 100644 --- a/README.md +++ b/README.md @@ -1335,7 +1335,7 @@ Implementation notes: ### 9.7 - GTS Type Schema Traits (`x-gts-traits-schema` / `x-gts-traits`) -**OP#13 - Schema Traits Validation**: Validate that `x-gts-traits` values in derived schemas conform to the `x-gts-traits-schema` defined in their base schemas. Verify that, for non-abstract types, all required trait properties in the effective trait-schema are resolved (via explicit value in the chain-merged `x-gts-traits` or via `default` in the effective trait-schema), and that trait values satisfy the effective trait-schema's other constraints. Trait values set by an ancestor are immutable — descendants MUST NOT override them with a different value. Both `x-gts-traits-schema` and `x-gts-traits` are schema-only keywords and MUST NOT appear in instances. `x-gts-traits-schema` MUST be a valid JSON Schema subschema (object, `true`, or `false`). Uses the same validation endpoints (`/validate-type-schema`, `/validate-entity`). +**OP#13 - Schema Traits Validation**: Validate that `x-gts-traits` values in derived schemas conform to the `x-gts-traits-schema` defined in their base schemas. Verify that, for non-abstract types, all required trait properties in the effective trait-schema are resolved (via explicit value in the chain-merged `x-gts-traits` or via `default` in the effective trait-schema), and that the chain-merged trait values satisfy the effective trait-schema's other constraints (including `const`, which a publisher uses to lock individual trait values across descendants — see §9.7.5). Both `x-gts-traits-schema` and `x-gts-traits` are schema-only keywords and MUST NOT appear in instances. `x-gts-traits-schema` MUST be a valid JSON Schema [subschema](https://json-schema.org/learn/glossary#subschema) (object, `true`, or `false`). Uses the same validation endpoints (`/validate-type-schema`, `/validate-entity`). A **schema trait** is a semantic annotation attached to a GTS Type Schema that describes **system behaviour** for processing instances of that type. Traits are not part of the object data model — they do not define instance properties. Instead, they configure cross-cutting concerns such as: @@ -1364,12 +1364,9 @@ A single schema MAY contain both keywords. This is explicitly allowed and useful When `x-gts-traits-schema` is an object subschema, the **effective** trait-schema (after chain aggregation per §9.7.5) MUST constrain trait values to JSON objects. -Because `x-gts-traits-schema` is an ordinary JSON Schema subschema, standard JSON Schema applies inside it without GTS reinventing anything: +Because `x-gts-traits-schema` is an ordinary JSON Schema subschema, all standard JSON Schema constructs apply inside it with their normal semantics; implementations MUST NOT invent a custom reference mechanism for `$ref`. -- `$ref` resolves per ordinary JSON Schema `$ref` rules (base URI resolution + JSON Pointer fragments). Implementations MUST NOT invent a custom reference mechanism. -- `allOf` / `oneOf` / `anyOf` / `not` carry their normal JSON Schema composition semantics — `allOf` is a logical AND over its subschemas at validation time, as for any other JSON Schema use. -- `properties` / `required` / `additionalProperties` / numeric and string constraints / `const` / `enum` / etc. behave as in any other JSON Schema. -- The trait shape MAY be declared **inline**, **referenced** from a standalone trait-schema registered as an ordinary GTS Type via `$ref`, or **composed** via `allOf` of inline parts and references. The choice is an authoring decision — inline keeps the trait surface private to the host and inheriting the host's ACL; the `$ref`-to-registered-type form is appropriate when the trait surface should be a separately governed artifact. +The trait shape MAY be declared **inline**, **referenced** from a standalone trait-schema registered as an ordinary GTS Type via `$ref`, or **composed** via `allOf` of inline parts and references. The choice is an authoring decision — inline keeps the trait surface private to the host and inheriting the host's ACL; the `$ref`-to-registered-type form is appropriate when the trait surface should be a separately governed artifact. **Inheritance along the host-type derivation chain happens at the registry level, not at the author level.** A descendant host type does NOT need to repeat the ancestor's `x-gts-traits-schema` inside its own — the registry composes all `x-gts-traits-schema` declarations encountered along the host's `$id` chain via JSON Schema `allOf` (see §9.7.5). A descendant MAY write an explicit `allOf` that includes a `$ref` to an ancestor's `x-gts-traits-schema`; doing so is redundant under chain aggregation but not invalid (consistent with the JSON Schema extension framing — see [`adr/0001-derivation-form.md`](adr/0001-derivation-form.md)). @@ -1379,7 +1376,9 @@ See [`adr/0002-x-gts-traits-schema.md`](adr/0002-x-gts-traits-schema.md) for the #### 9.7.2 Trait schema definition (`x-gts-traits-schema`) -A base schema declares the trait schema — the shape and defaults of all trait fields. This tells the system which traits exist and what values are acceptable. +A type schema declares the trait shape — property names, types, constraints, and `default` values. Any type in the `$id` chain (base or descendant) MAY contribute its own `x-gts-traits-schema`; the registry composes all such declarations along the chain via JSON Schema `allOf` into a single effective trait-schema (see §9.7.5). + +The same derivation compatibility principle that governs host body schemas (§3.1) applies to `x-gts-traits-schema`: every value valid against the descendant's effective trait-schema MUST also be valid against each ancestor's trait-schema. This is enforced naturally by the `allOf` composition — contradictions across the chain (e.g., conflicting types, narrowed constraints that don't overlap, or different `default`s for the same property) produce an unsatisfiable effective trait-schema and fail registration. Typically a base declares the initial trait shape and descendants **narrow** existing trait properties (tighten constraints, `const`, narrower enums). Descendants MAY also **extend** the trait surface by introducing new top-level properties — but only if no ancestor's `x-gts-traits-schema` declares `additionalProperties: false` (or another restriction that would reject the new property); otherwise the new property is treated as "additional" against that ancestor's branch in the `allOf` composition and validation fails, by the same mechanic as §3.1 governs for host bodies. **Inline definition:** @@ -1390,7 +1389,6 @@ A base schema declares the trait schema — the shape and defaults of all trait "type": "object", "x-gts-traits-schema": { "type": "object", - "additionalProperties": false, "properties": { "topicRef": { "description": "GTS ID of the topic/stream where events of this type are published.", @@ -1506,23 +1504,27 @@ Given an inheritance chain `S₀ → S₁ → … → Sₙ`: - **Immutable defaults:** `default` values declared in an ancestor's `x-gts-traits-schema` MUST NOT be changed by a descendant's `x-gts-traits-schema`. If a descendant redeclares a trait property with a different `default`, schema validation MUST fail. - **Trait value merge** - - The registry MUST build an *effective traits object* by collecting all `x-gts-traits` objects encountered in the chain (left-to-right). - - **Immutable-once-set:** Once a trait key is assigned a concrete value by a schema in the chain, **no descendant may override it**. If a descendant's `x-gts-traits` provides a different value for a key already set by an ancestor, schema validation MUST fail. Providing the **same** value is permitted (idempotent). - - Defaults declared in the effective trait schema SHOULD be used as normal JSON Schema defaults to produce a complete effective traits object. + - The registry MUST build an *effective traits object* by walking the type's `$id` chain root → leaf and applying each layer's `x-gts-traits` as a [JSON Merge Patch (RFC 7396)](https://datatracker.ietf.org/doc/html/rfc7396) against the chain-merged object so far. Top-level scalar / array / `null` leaves are overwritten by the descendant (last-wins). Object-valued top-level traits merge **recursively** — fields of an ancestor's object trait that the descendant does not restate are preserved. + - **Arrays replace wholesale** at any depth (per RFC 7396). Authors who need item-level composability SHOULD model the data as a keyed object instead of an array. + - **`null` at any depth deletes that key** from the effective object (per RFC 7396). The principal use case is to revert an ancestor-set value and let the trait-schema's `default` re-apply via the materialization step described in the Completeness check below — that is, a descendant writes `"": null` to "fall back to the schema default" without picking a specific value. If the deleted key is `required` and has no `default`, the completeness check fails registration for non-abstract types (the descendant must then either mark itself abstract or accept that "delete + required + no default" is an unresolvable contract). Authors who want `null` as an *intended* trait value cannot express it via this merge and must use a sentinel value documented as part of the trait shape. + - Defaults declared in the effective trait-schema MUST be materialized into the effective traits object before the Completeness check runs (per ADR-0003): for every property declared in the effective trait-schema with a `default` and not present in the chain-merged object, the registry MUST substitute the default value. The Completeness check below (OP#13) operates on the resulting *materialized* effective traits object. + - A publisher who wants a trait value to be **locked** across all descendants of a base type SHOULD declare `"const": ` for that property in `x-gts-traits-schema`. A descendant attempting to override the value will fail the standard JSON Schema validation that runs against the effective trait-schema (per the Completeness check below). No GTS-specific "immutability" rule is required — `const` is the mechanism. + - A descendant MAY redeclare a trait value with the same value the ancestor already declared (idempotent restatement). + - See [`adr/0004-x-gts-traits-merge-strategy.md`](adr/0004-x-gts-traits-merge-strategy.md) for the rationale. - **Validation** - **Completeness check** (registration-time): For types whose `x-gts-abstract` is not `true`, the registry MUST verify that the *materialized* effective traits object validates against the effective trait-schema using standard JSON Schema validation. "Materialized" means: defaults declared in the effective trait-schema for properties not present in the chain-merged effective traits object are substituted in before validation. If validation fails — in particular, if a `required` property of the effective trait-schema has no chain-assigned value and no default — registration MUST fail. For types with `x-gts-abstract: true`, this completeness check is skipped; descendants are expected to close any unresolved required traits. See [`adr/0003-x-gts-traits-completeness.md`](adr/0003-x-gts-traits-completeness.md) for the rationale. - If the effective trait schema cannot be satisfied (e.g., contradictory constraints introduced across the chain), schema validation MUST fail. - - If a descendant attempts to override a trait value already set by an ancestor with a different value, schema validation MUST fail. -**Example — immutable trait override (failure):** +**Example — descendant override and `const` lock:** Consider a 3-level chain: `base → audit_event → most_derived_event`. -- `audit_event` sets `x-gts-traits.topicRef` to `gts.x.core.events.topic.v1~x.core._.audit.v1` -- `most_derived_event` attempts to set `x-gts-traits.topicRef` to `gts.x.core.events.topic.v1~x.core._.notification.v1` +- `base.x-gts-traits-schema.properties.indexed.const = true` — the publisher locks `indexed`. +- `audit_event.x-gts-traits` sets `topicRef = gts.x.core.events.topic.v1~x.core._.audit.v1`. +- `most_derived_event.x-gts-traits` sets `topicRef = gts.x.core.events.topic.v1~x.core._.notification.v1`. -Validation of `most_derived_event` MUST fail because `topicRef` was already set by `audit_event` and the new value differs. +Effective traits for `most_derived_event`: `{ "indexed": , "topicRef": ".../notification.v1" }`. The override of `topicRef` is permitted (last-wins). If `most_derived_event` also tried to set `"indexed": false`, registration would fail — not because of a GTS-specific immutability rule, but because the materialized effective traits object would not satisfy the `const: true` constraint declared on `indexed` in the effective trait-schema. These rules are intentionally aligned with existing JSON Schema composition semantics and GTS schema chaining practices. diff --git a/adr/0004-x-gts-traits-merge-strategy.md b/adr/0004-x-gts-traits-merge-strategy.md new file mode 100644 index 0000000..e1ac1b2 --- /dev/null +++ b/adr/0004-x-gts-traits-merge-strategy.md @@ -0,0 +1,525 @@ +# ADR-0004: `x-gts-traits` merge strategy — JSON Merge Patch (RFC 7396) along the `$id` chain + +- **Status:** Accepted +- **Date:** 2026-05-27 +- **Deciders:** GTS spec maintainers +- **Consulted:** — +- **Supersedes:** — +- **Superseded by:** — + +## Context and Problem Statement + +### What `x-gts-traits` does + +A GTS type carries trait values via `x-gts-traits` (a plain JSON object), validated against the effective trait-schema declared by `x-gts-traits-schema` (per ADR-0002). Trait values configure system behaviour — retention, indexing, routing, association links, compliance classifications — and do not appear in instance payloads. + +### The chain question + +A `$id` chain like `A~ → B~ → C~` may have `x-gts-traits` declared at any subset of the layers. The registry computes an *effective traits object* per type by combining those declarations. The spec must commit to: + +- Whether declarations from ancestors flow into descendants at all (merge or no merge). +- If they do, how shared keys are resolved (override vs lock). +- If they do, how nested object values combine (shallow vs deep). + +### Traits do not affect derivation compatibility + +Unlike the host type's `properties` / `required` / `additionalProperties` (which DO participate in OP#12), `x-gts-traits` values are publisher metadata. They configure how the system treats instances of the type; they do not constrain instance payloads. The merge semantic can therefore be chosen for ergonomics and clarity, not for compatibility-preservation. + +### Worked example + +A base event type sets a default retention; a vendor wants either (a) to inherit it silently or (b) to specialize for their own derived event type. Different merge rules give different outcomes: + +```jsonc +// Base event type +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": { "type": "string", "default": "P30D" }, + "indexed": { "type": "boolean", "default": false } + } + }, + "x-gts-traits": { "retention": "P30D" } +} + +// Derived event type — wants 90-day retention for its own subtree +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~vendor.audit.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { "retention": "P90D" } +} +``` + +What is the effective traits object for `audit.v1~`? + +- Under **no merge**: `{ "retention": "P90D" }` (ancestor's `x-gts-traits` is invisible). +- Under **shallow last-wins merge**: `{ "retention": "P90D" }` (descendant wins) plus any other keys from ancestor not overridden. +- Under **shallow immutable-once-set**: registration fails — `retention` was claimed by the ancestor with `"P30D"`, descendant's `"P90D"` conflicts. +- Under **per-property keyword**: depends on how the trait-schema declares the property's merge behaviour. + +The ADR commits to one of these choices. + +## Decision Drivers + +- **Ergonomics for the common case.** Authors should be able to specialize a trait in a descendant without ceremony — the OOP-override mental model. Forbidding the natural override puts friction on legitimate use. +- **Publisher's ability to lock values.** A publisher who really wants a trait fixed across all descendants should have a clear, standard mechanism — without inventing a GTS-specific keyword. +- **Predictability.** The merge semantic should be easy to read off the chain, with no surprises about nested object behaviour, array semantics, or `null` interpretations. +- **No GTS-specific machinery if JSON Schema already provides it.** ADR-0001 framing: extend JSON Schema with rules only when necessary; lean on existing constructs (`const`, `default`) where they fit. + +## Considered Options + +### Option 1 — No merge (each type self-contained) + +Every type's `x-gts-traits` is read as the whole truth for that type alone. Ancestor declarations are ignored when computing the descendant's effective traits. (Defaults from the effective trait-schema still materialize per ADR-0003, since defaults live in the schema, not in `x-gts-traits`.) + +*Example.* Base sets two trait values; derived sets none. + +```jsonc +// Base +{ + "$id": "gts://gts.x.example.event.v1~", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": { "type": "string" }, + "topicRef": { "type": "string" } + } + }, + "x-gts-traits": { "retention": "P30D", "topicRef": "events" } +} + +// Derived — no x-gts-traits +{ + "$id": "gts://gts.x.example.event.v1~vendor.audit.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }] +} +``` + +Effective traits object for `audit.v1~` under Option 1: `{}` — the base's `retention` and `topicRef` are invisible. If `audit.v1~` wants either, it must restate them locally. + +- **+** Trivially simple semantics. No merge algorithm at all. +- **+** No override / locking question; each type stands alone. +- **−** Massive boilerplate: every derived type must restate every trait it inherits, or it loses them. +- **−** Defaults from `x-gts-traits-schema` (used by ADR-0003 materialization) become the *only* inheritance path; explicit `x-gts-traits` on ancestors becomes documentation that no descendant actually uses. + +### Option 2a — Shallow merge, descendant-last-wins + +The effective traits object is computed by walking the chain root → leaf and applying each `x-gts-traits` object via shallow object assignment: top-level keys from later layers overwrite those from earlier layers. Object-valued traits are replaced wholesale (not recursively merged). Publishers who need to lock a value declare `"const": ` for that property in `x-gts-traits-schema`; the standard JSON Schema validation that runs over the effective traits object (per ADR-0003) catches any descendant attempting to override. + +*Example.* Base sets two trait values; derived overrides one. + +```jsonc +// Base +{ + "$id": "gts://gts.x.example.event.v1~", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": { "type": "string" }, + "topicRef": { "type": "string" } + } + }, + "x-gts-traits": { "retention": "P30D", "topicRef": "events" } +} + +// Derived — only restates retention +{ + "$id": "gts://gts.x.example.event.v1~vendor.audit.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { "retention": "P90D" } +} +``` + +Effective traits object for `audit.v1~` under Option 2a: `{ "retention": "P90D", "topicRef": "events" }`. The descendant's `retention` wins; `topicRef` is inherited. + +- **+** Matches the OOP-override intuition most authors bring. +- **+** No GTS-specific lock mechanism needed — `const` in the trait-schema does the job, validated by ordinary JSON Schema. The publisher decides on a per-property basis (and the spec footprint stays small). +- **+** Simple to reason about: read root → leaf, latest declaration wins, no recursion. +- **+** Compatible with ADR-0003's "chain-merged then materialized" model — the materialization step (defaults applied for missing keys) composes cleanly after the merge. +- **−** Object-valued traits replace wholesale; if a publisher uses a nested trait shape (`routing: { topic, partitionKey }`), a descendant that overrides `routing` must restate every nested field they want to keep. In practice authors should prefer flat trait shapes, or use per-field traits rather than nested objects. +- **−** A descendant can change an ancestor's value the publisher *intended* to be fixed, if the publisher forgot to declare `const`. The mitigation is convention + linting; the spec gives the tool but does not enforce its use. + +### Option 2b — Shallow merge, immutable-once-set + +The effective traits object is computed by walking the chain root → leaf; once a key has been set by any layer, later layers MUST either omit it or repeat the same value. Conflicting redeclaration causes registration to fail. + +*Example.* Using the same base as Option 2a (`retention: "P30D"` declared on the base): + +```jsonc +// Derived A — tries to override +{ + "$id": "gts://gts.x.example.event.v1~vendor.audit.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { "retention": "P90D" } // ← conflict +} +``` + +Registration of derived A FAILS under Option 2b — `retention` was already claimed by the base as `"P30D"`, and the descendant's `"P90D"` is a different value. + +```jsonc +// Derived B — repeats the same value (allowed, idempotent) +{ + "$id": "gts://gts.x.example.event.v1~vendor.audit.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { "retention": "P30D" } +} +``` + +Registration of derived B SUCCEEDS — value matches. + +```jsonc +// Derived C — omits the key entirely +{ + "$id": "gts://gts.x.example.event.v1~vendor.audit.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }] +} +``` + +Registration of derived C SUCCEEDS; effective traits inherit `retention: "P30D"` from the base. + +- **+** Ancestor's declaration is authoritative — descendants cannot silently break a publisher's policy. +- **+** Idempotent same-value repetition is permitted, so descendants who want to "re-state for documentation" can. +- **−** Brittle to authoring evolution: a chain authored bottom-up (descendant first, then ancestor populated) can fail when the ancestor adds a value that's already been claimed below. +- **−** Validation is more complex than standard JSON Schema — needs a chain-aware "first occurrence wins; later occurrences must match" check. +- **−** Contrary to the OOP-override mental model; trips up authors who expect descendants to specialize. +- **−** Redundant with `const`: a publisher who wants to lock a trait already has `const` in the trait-schema. The immutability rule duplicates this guarantee for the cases where the publisher *forgot to declare* `const` — which is arguably the wrong place to bake the safety net (it surprises the descendant author rather than nudging the publisher to be explicit). + +### Option 2c — RFC 7396 (JSON Merge Patch), descendant-last-wins *(chosen)* + +The merge follows RFC 7396 semantics: object values merge recursively (nested objects combine); arrays replace wholesale; `null` deletes a key. Override is last-wins (same as 2a) at the leaf level, but nested objects compose rather than replace. + +*Example.* Use a nested-object trait to expose the difference from Option 2a. + +```jsonc +// Base +{ + "$id": "gts://gts.x.example.event.v1~", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "routing": { + "type": "object", + "properties": { + "topic": { "type": "string" }, + "partitionKey": { "type": "string" } + } + } + } + }, + "x-gts-traits": { + "routing": { "topic": "events", "partitionKey": "userId" } + } +} + +// Derived — overrides only `topic` +{ + "$id": "gts://gts.x.example.event.v1~vendor.audit.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { + "routing": { "topic": "orders" } + } +} +``` + +Effective traits for `audit.v1~`: + +- Under **Option 2a (shallow)**: `{ "routing": { "topic": "orders" } }` — `partitionKey` is **lost** because the whole `routing` object is replaced wholesale. +- Under **Option 2c (RFC 7396)**: `{ "routing": { "topic": "orders", "partitionKey": "userId" } }` — `partitionKey` is preserved by the recursive merge. + +This is the main practical difference between Options 2a and 2c. If a descendant wanted to actively "delete" `partitionKey` under 2c, it could write `"partitionKey": null`. + +- **+** Standard, well-defined semantics with mature implementations. +- **+** Preserves nested fields automatically. +- **−** `null` semantics ("delete this key") is a foreign concept for traits — traits aren't usually "removed," they're either set or absent. Authors who write `null` for a different reason would get surprised by the delete behaviour. +- **−** Array semantics (always replace) is a defensible choice but introduces a sub-rule readers have to remember; under shallow we don't say anything about arrays at all (because objects don't combine recursively in the first place). +- **−** "What does the effective traits object look like?" becomes a non-trivial computation; harder to predict by reading the schemas top-to-bottom. + +### Option 3 — Per-property author-controlled merge + +A new GTS keyword inside `x-gts-traits-schema` (e.g., `x-gts-trait-merge: "lock" | "override" | "deep-merge"`) lets the publisher declare per-property how merging behaves. + +*Example.* Publisher locks `retention` but allows override of `topicRef`: + +```jsonc +// Base +{ + "$id": "gts://gts.x.example.event.v1~", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": { + "type": "string", + "x-gts-trait-merge": "lock" + }, + "topicRef": { + "type": "string", + "x-gts-trait-merge": "override" + } + } + }, + "x-gts-traits": { "retention": "P30D", "topicRef": "events" } +} + +// Derived — tries to override both +{ + "$id": "gts://gts.x.example.event.v1~vendor.audit.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { + "retention": "P90D", // ← rejected: property locked + "topicRef": "audit" // ← accepted: override allowed + } +} +``` + +Registration of the derived type fails at `retention` (locked). To succeed, the descendant must drop the `retention` override. + +- **+** Maximum flexibility — different traits get different inheritance contracts. +- **−** Adds new spec vocabulary that every implementation must implement and every author must learn. +- **−** Multiple modes multiply test surface; conformance suite grows. +- **−** Mostly redundant with `const` + default behaviour: most legitimate needs (lock vs override) are already expressible without the new keyword. The remaining cases (deep-merge per property) are rare and can be re-visited later if real demand surfaces. + +## Decision Outcome + +Chosen: **Option 2c — RFC 7396 (JSON Merge Patch) along the `$id` chain, descendant-last-wins at the leaf, with `const` in `x-gts-traits-schema` as the publisher's lock mechanism.** + +### Normative consequences + +- The *effective traits object* of a GTS type T is computed by walking T's `$id` chain root → leaf, treating the chain-merged object so far as the "target" and each layer's `x-gts-traits` as a JSON Merge Patch (RFC 7396) applied to it. At the leaf (top-level scalar value, array, or `null`), descendant declarations replace ancestor declarations (last-wins). For object-valued top-level keys, the recursion descends and the same patch semantics apply to nested fields. +- **Arrays replace wholesale** at any level (per RFC 7396). If a publisher needs item-level composability of an array trait, they SHOULD model that as a top-level keyed object instead of an array. +- **`null` at any level deletes that key** from the effective object (per RFC 7396). The principal use case is to revert an ancestor-set value and let the trait-schema's `default` re-apply via ADR-0003 materialization — see Worked example D. A descendant writes `"": null` to invoke this. If the deleted key is `required` with no `default`, the completeness check (ADR-0003) fails registration for non-abstract types — the descendant either has to mark itself abstract or accept that "delete + required + no default" is an unresolvable contract. Authors who want `null` as an *intended* trait value cannot express it via this merge and must use a sentinel value documented as part of the trait shape. +- A publisher who wants to **lock** a trait value across all descendants of a base type SHOULD declare `"const": ` for that property inside `x-gts-traits-schema`. A descendant that attempts to set a different value for that property will fail the standard JSON Schema validation that runs over the effective traits object (per ADR-0003) — no GTS-specific lock rule is needed. +- A publisher who wants to provide a **soft default** that descendants may override SHOULD declare `"default": ` for that property. ADR-0003's materialization step applies the default if no chain-merged value is present. +- A descendant MAY redeclare a trait value with the same value the ancestor already declared (idempotent restatement, useful for documentation). This is naturally permitted by last-wins. + +### Worked example A — natural override (last-wins) + +Base type sets `retention` via `x-gts-traits`; descendant overrides: + +```jsonc +// Base +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": { "type": "string" }, + "topicRef": { "type": "string" } + } + }, + "x-gts-traits": { "retention": "P30D", "topicRef": "events" } +} + +// Derived — wants 90-day retention +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~vendor.audit.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { "retention": "P90D" } +} +``` + +Effective traits for `audit.v1~`: `{ "retention": "P90D", "topicRef": "events" }`. `retention` is the descendant's value (last-wins); `topicRef` carried in from the chain. Both registrations succeed. + +### Worked example B — nested-object trait, recursive merge + +This is the case where JSON Merge Patch semantics show their value: a descendant updating one field of a nested-object trait without restating the others. + +```jsonc +// Base +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "routing": { + "type": "object", + "properties": { + "topic": { "type": "string" }, + "partitionKey": { "type": "string" } + } + } + } + }, + "x-gts-traits": { + "routing": { "topic": "events", "partitionKey": "userId" } + } +} + +// Derived — overrides only `topic` +{ + "$id": "gts://gts.x.example.event.v1~vendor.audit.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { + "routing": { "topic": "orders" } + } +} +``` + +Effective traits for `audit.v1~`: `{ "routing": { "topic": "orders", "partitionKey": "userId" } }`. The descendant's `topic` overrides; `partitionKey` is preserved because RFC 7396 descends into `routing` and patches at the leaf level. + +A descendant that actively wants to remove `partitionKey` writes it as `null`: + +```jsonc +{ + "$id": "gts://gts.x.example.event.v1~vendor.unkeyed.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { + "routing": { "partitionKey": null } + } +} +``` + +Effective traits: `{ "routing": { "topic": "events" } }`. `partitionKey` is gone (RFC 7396 delete semantics); `topic` is inherited unchanged. + +### Worked example C — locking values via `const` + +A publisher who wants `indexed` fixed across all descendants declares it `const` inside `x-gts-traits-schema`. No new GTS keyword is needed — the JSON Schema `const` keyword is the lock mechanism, validated by the ordinary JSON Schema validation that ADR-0003 already runs over the effective traits object. + +```jsonc +// Base — locks `indexed` to true; allows `retention` to be overridden +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": { "type": "string", "default": "P30D" }, + "indexed": { "type": "boolean", "const": true } + } + }, + "x-gts-traits": { "indexed": true } +} +``` + +Descendant A — overrides `retention` only, leaves `indexed` alone: + +```jsonc +{ + "$id": "gts://gts.x.example.event.v1~vendor.audit.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { "retention": "P90D" } +} +``` + +Effective traits: `{ "retention": "P90D", "indexed": true }`. **Succeeds** — `retention` is freely overridable, `indexed` is preserved from the chain. Validates against the effective trait-schema (`indexed: true` satisfies `const: true`). + +Descendant B — tries to override the locked value: + +```jsonc +{ + "$id": "gts://gts.x.example.event.v1~vendor.adhoc.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { "indexed": false } +} +``` + +Effective traits after merge: `{ "indexed": false }` (descendant's value wins the merge — last-wins doesn't pre-judge values). But the materialized object is then validated against the effective trait-schema, which says `indexed: { "const": true }`. `false ≠ true` → JSON Schema validation fails → registration **fails**. The lock is enforced entirely by standard JSON Schema; the spec does not need a GTS-specific "immutable" rule to achieve it. + +**Pattern.** Publisher chooses, per-property, in `x-gts-traits-schema`: + +- `"const": ` — the value is **locked**; descendants that try to set anything else fail JSON Schema validation. +- `"default": ` — the value is a **soft default**; descendants who don't set the property inherit it, descendants who do set it can override freely (last-wins). +- neither — the property is open; descendants set or inherit values as they wish. + +### Worked example D — `null` to fall back to the trait-schema default + +The main motivation for the `null`-as-delete semantic from RFC 7396 is its clean interaction with ADR-0003 *materialization*: after the chain-merge produces the effective traits object, defaults declared in the effective trait-schema are applied for properties **not present** in that object. A descendant that writes `"": null` therefore removes the chain-merged value for that key, and the materialization step then fills it back in from the schema's `default` (if any). + +Example: ancestor opinionates the value to a non-default; a specific descendant wants to revert to the schema default without picking its own specific value. + +```jsonc +// Base — schema default is "P7D", ancestor opinionates to "P30D" +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "gts://gts.x.example.event.v1~", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": { "type": "string", "default": "P7D" } + } + }, + "x-gts-traits": { "retention": "P30D" } +} + +// Derived — revert to the schema default +{ + "$id": "gts://gts.x.example.event.v1~vendor.shortlived.v1~", + "allOf": [{ "$ref": "gts://gts.x.example.event.v1~" }], + "x-gts-traits": { "retention": null } +} +``` + +Effective traits computation for `shortlived.v1~`: + +1. Chain-merge: start from `{}`, apply ancestor's patch → `{ "retention": "P30D" }`; apply descendant's patch with `null` at the leaf → `{}` (key removed). +2. ADR-0003 materialization: `retention` is missing; the effective trait-schema declares `default: "P7D"`; substitute → `{ "retention": "P7D" }`. + +Effective traits: `{ "retention": "P7D" }`. The descendant successfully reverted to the schema default. + +**Failure mode.** If the deleted key is `required` in the effective trait-schema AND has no `default`, then after merge + materialization the required field remains unresolved. For a non-abstract type, the completeness check (ADR-0003) fails registration. Authors who genuinely need to delete a `required`-without-`default` trait must mark the type `x-gts-abstract: true` (i.e., explicitly declare "this layer doesn't satisfy the contract; descendants will"). + +### Edge cases + +- **Object-valued trait, descendant overrides one nested field.** Per Worked example B: ancestor's other nested fields are preserved; only the field the descendant restates is overridden. No need for descendants to restate the whole nested object. +- **Array-valued trait, descendant declares a different array.** Arrays replace wholesale (per RFC 7396). If publishers need per-element composability, they should model the data as a keyed object rather than an array. +- **`null` in `x-gts-traits` deletes the key.** Per RFC 7396, a leaf value of `null` removes that key from the effective object. The primary use case is letting ADR-0003 materialization re-apply the trait-schema's `default` for that key (see Worked example D). If the deleted key is `required` and has no `default`, the completeness check (ADR-0003) fails registration for non-abstract types. Authors who want `null` as an actual trait *value* cannot express it via this merge — they would need a sentinel (e.g., `"unset"`) and document it as part of the trait shape. +- **Ancestor sets a value; descendant repeats the same value.** Permitted; both layers agree. +- **No `x-gts-traits` anywhere in the chain.** Effective traits object is empty; ADR-0003's materialization fills in any defaults declared in the effective trait-schema; the completeness check (for non-abstract types) then validates the materialized object. +- **`x-gts-traits` on an abstract base.** Carried forward into descendants exactly like any other layer; abstract status affects only completeness checking per ADR-0003, not merge. + +## Implications + +- **§9.7.5 ("Trait merge and validation semantics")** carries the normative wording of RFC 7396 merge along the `$id` chain and the `const`-based lock mechanism. +- **ADR-0003** stays correct as written; the "chain-merged effective traits object" referenced there is now formally defined as the result of applying each layer's `x-gts-traits` as a JSON Merge Patch (RFC 7396) to the chain-merged object so far, root → leaf. +- **OP#13 description (§9.7)** is unaffected; it speaks generically of "chain-merged" values. +- **§9.11.4 (modifiers ↔ traits)** is unaffected; completeness keying on `x-gts-abstract` is independent of merge policy. +- **Reference implementations (gts-go, gts-rust)** must implement RFC 7396 merge along the chain. Available implementations exist in both ecosystems. The registry MUST NOT enforce a "different value MUST fail" rule on its own — it relies on standard JSON Schema validation against the effective trait-schema (which catches `const` violations naturally). +- **Conformance test suite** should exercise: (a) descendant overrides a top-level scalar — succeeds (last-wins); (b) descendant overrides one field of a nested-object trait — other nested fields preserved; (c) descendant overrides an array-valued trait — array replaces wholesale; (d) descendant writes `null` at a leaf — the key is removed; (e) descendant repeats the same value — succeeds (idempotent); (f) publisher locks via `const`, descendant attempts override — fails JSON Schema validation; (g) chain with three layers; middle layer overrides base; leaf overrides middle. + +## Pros and Cons of the Options + +### Option 1 — No merge + +- **+** Simplest semantics. +- **−** Boilerplate-heavy; defeats the point of declaring traits on ancestors. + +### Option 2a — Shallow last-wins + +- **+** Simplest merge algorithm. +- **+** Locking handled by existing `const`; no new GTS vocabulary. +- **−** Wholesale object replacement for nested traits; descendants must restate every nested field they want to keep, even if they only touch one. Pushes authors toward unnaturally flat trait shapes. + +### Option 2b — Shallow immutable-once-set + +- **+** Strong publisher guarantee. +- **−** Redundant with `const`; brittle to evolution; non-standard validation; contrary to OOP intuition. + +### Option 2c — RFC 7396 (chosen) + +- **+** Standard, well-specified merge semantics with mature implementations in major language ecosystems. +- **+** Preserves nested fields automatically — descendants can update one field of a nested-object trait without restating the rest. +- **+** Provides `null`-as-delete as a clean way for descendants to remove an inherited trait when needed. +- **+** Locking still handled by `const`; the GTS-side spec footprint remains tiny. +- **−** `null` carries delete semantics; authors who want `null` as a genuine trait value cannot express it via the merge and must use a sentinel. +- **−** Arrays replace wholesale at any depth — an extra sub-rule readers must remember, in exchange for predictable RFC 7396 semantics. +- **−** Predicting the effective traits object requires running the merge in head (more complex than reading shallow assignment). Mitigated by good tooling that displays the effective traits object. + +### Option 3 — Per-property keyword + +- **+** Maximum flexibility. +- **−** New vocabulary, larger spec surface, mostly redundant with `const` + last-wins for realistic needs. + +## More Information + +Cross-references inside this specification: §9.7 (`x-gts-traits-schema` / `x-gts-traits`), §9.11 (`x-gts-final` / `x-gts-abstract`), OP#13 (Schema Traits Validation). Related ADRs: [`adr/0001-derivation-form.md`](0001-derivation-form.md) (extension framing), [`adr/0002-x-gts-traits-schema.md`](0002-x-gts-traits-schema.md) (trait-schema as JSON Schema subschema with chain aggregation), [`adr/0003-x-gts-traits-completeness.md`](0003-x-gts-traits-completeness.md) (completeness check at type registration). + +External references: + +- [`const` in JSON Schema](https://json-schema.org/understanding-json-schema/reference/generic#constant-values) +- [`default` in JSON Schema](https://json-schema.org/understanding-json-schema/reference/annotations) +- [RFC 7396 — JSON Merge Patch](https://datatracker.ietf.org/doc/html/rfc7396) From f3852dd91800309c17dab35068019d7347b9d53f Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Thu, 28 May 2026 16:57:38 +0300 Subject: [PATCH 5/7] docs(spec): relax default-immutability and finalize v0.12 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - README §9.7.5/§9.7.2: remove the "immutable defaults" rule for x-gts-traits-schema. Defaults are JSON Schema annotations and do not participate in OP#12 narrowing (narrowing is defined over the set of valid instances, which `default` does not affect); descendants MAY redeclare them. Publishers who want a trait value to be fixed across descendants use `const` — the same lock mechanism already endorsed by ADR-0004 for trait values. This removes the asymmetry with regular property defaults, which were never restricted. - adr/0003-x-gts-traits-completeness.md: drop the stale "subject to the immutable-once-set rule" parenthetical in the algorithm description; reference ADR-0004's RFC 7396 merge instead. ADR-0004 explicitly chose Option 2c (RFC 7396) over Option 2b (immutable-once-set), so the parenthetical was a leftover from an earlier draft. - README version footer: bump to 0.12 (carried over from the prior commit, now consolidated here). Signed-off-by: Aviator 5 --- README.md | 74 ++++++++++--------- adr/0003-x-gts-traits-completeness.md | 2 +- ...erce.orders.order_placed.v1.0~.schema.json | 10 +-- ...erce.orders.order_placed.v1.1~.schema.json | 10 +-- ...x.core.idp.contact_created.v1~.schema.json | 10 +-- 5 files changed, 55 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 7f52f87..d533650 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ - -> **VERSION**: GTS specification draft, version 0.11 + +> **VERSION**: GTS specification draft, version 0.12 # Global Type System (GTS) Specification @@ -112,6 +112,7 @@ See the [Practical Benefits for Service and Platform Vendors](#51-practical-bene | 0.9 | Add `x-gts-final` and `x-gts-abstract` schema modifiers; enforce final/abstract semantics in OP#6 and OP#12 | | 0.10 | BREAKING: terminology unified around GTS Type / GTS Instance; rename API fields `schema_id` → `type_id` (also `old_schema_id`/`new_schema_id`/`to_schema_id`/`selected_schema_id_field`); rename API field `is_schema` → `is_type` (type-definition vs instance discriminator); `type_id` MUST be a GTS Type Identifier or `null` — no longer falls back to JSON Schema dialect URL; rename endpoints `/validate-schema` → `/validate-type`, `/schemas` → `/types`; rename OP#12 'Schema vs Schema Validation' → 'Type Derivation Validation'; rename OpenAPI components `ValidateSchemaRequest` → `ValidateTypeRequest`, `SchemaRegister` → `TypeRegister`; rename example directories `examples/**/schemas/` → `examples/**/types/` (file extensions `.schema.json` retained); add Terminology section | | 0.11 | Introduce term **GTS Type Schema** as the canonical definition of a GTS Type; remove the standalone `Schema` term from Terminology; rewrite `GTS Type` entry to name the abstract registered entity; rename `GTS Type Registry` → `GTS Registry` (registry now scopes both Type Schemas and well-known Instances). **Conformance tests for reference implementations** also updated: rename API endpoints `/validate-type` → `/validate-type-schema` and `/types` → `/type-schemas`; rename OpenAPI components `TypeRegister` → `TypeSchemaRegister`, `ValidateTypeRequest` → `ValidateTypeSchemaRequest`; rename request field `TypeSchemaRegister.schema` → `TypeSchemaRegister.type_schema`; rename helper `validate_type` → `validate_type_schema`. | +| 0.12 | BREAKING: reframe GTS Type Schemas as a dialect-agnostic JSON Schema extension; the prior `$defs MUST NOT` and post-Draft-07-keyword restrictions are dropped; derivation compatibility and the finality guard use the chained `$id` alone, `allOf`+`$ref` recommended but not required (ADR-0001). `x-gts-traits-schema` becomes a JSON Schema subschema (object/`true`/`false`); the registry chain-aggregates declarations along the `$id` chain via `allOf` (ADR-0002). Trait completeness is keyed on `x-gts-abstract` and enforced on non-abstract types against the materialized effective traits object (ADR-0003). Trait-value merge follows JSON Merge Patch (RFC 7396); cross-descendant locking moves to standard JSON Schema `const` in `x-gts-traits-schema` (ADR-0004). The four document-level keywords (`x-gts-final`, `x-gts-abstract`, `x-gts-traits-schema`, `x-gts-traits`) MUST appear at the schema top level and are rejected (fail fast) when nested in a subschema (§9.7.1, §9.11). | ## Terminology @@ -119,7 +120,7 @@ This specification uses the following terms with precise meanings: - **GTS Type**: a type entity identified by a GTS Type Identifier and defined by a GTS Type Schema. A GTS Type may exist as a standalone document (e.g., a `*.schema.json` file), be exchanged between systems, or be stored in a GTS Registry. - **GTS Type Identifier**: a canonical GTS identifier ending with `~` that identifies a GTS Type. -- **GTS Type Schema**: the canonical definition of a GTS Type. It is an **extension of JSON Schema** that adds GTS-specific keywords (`x-gts-*`) and a set of registry-enforced semantic rules describing the type's instance shape, traits, and derivation. GTS is **dialect-agnostic**: the underlying JSON Schema dialect of any concrete Type Schema is set by its `$schema` (the spec's examples use Draft-07 as the baseline for maximum interoperability, but Draft 2019-09 and 2020-12 are equally supported). GTS does not publish a dedicated `$schema` URI or meta-schema and is therefore not a [JSON Schema Dialect](https://json-schema.org/learn/glossary#dialect) in the formal sense; see §11.0 for details. +- **GTS Type Schema**: the canonical definition of a GTS Type — a JSON Schema document annotated with the GTS-specific keywords (`x-gts-*`), describing the type's instance shape, traits, and derivation. Implementations MAY accept alternative source forms (e.g., TypeSpec, YAML) provided they deterministically map to a canonical GTS Type Schema. The canonical form, used for interchange, validation, and registration, is the JSON Schema document. - **GTS Registry**: a registry that stores and resolves GTS entities — Type Schemas and well-known Instances — by GTS Identifier. @@ -1262,7 +1263,7 @@ It is recommended to make GTS Type references in JSON Schema `$ref` URI-compatib } ``` -Note: local JSON Pointer references (e.g. `"$ref": "#/definitions/Foo"`) are Draft-07 compliant and remain valid. The `gts://` recommendation applies only when `$ref` targets a GTS Type Identifier. Under Draft-07 the canonical container for reusable subschemas is `definitions`; the `$defs` keyword (introduced in 2019-09) MUST NOT be used in GTS Type Schemas. +Note: local JSON Pointer references (e.g. `"$ref": "#/definitions/Foo"` under Draft-07, or `"$ref": "#/$defs/Foo"` under Draft 2019-09+) remain valid. The `gts://` recommendation applies only when `$ref` targets a GTS Type Identifier. The canonical container for reusable subschemas follows the dialect declared by `$schema`: `definitions` for Draft-07, `$defs` for Draft 2019-09 and later; both are admissible in GTS Type Schemas. Implementation note: When `$ref` is expressed as `gts://...`, implementations should trim the `gts://` prefix and treat the remainder as the canonical GTS identifier for resolution, validation, comparison, and registry keys. The `gts://` prefix exists only to make `$ref` URI-compatible. @@ -1354,6 +1355,8 @@ Two JSON Schema annotation keywords are used together: **Schema-only keywords:** Both `x-gts-traits-schema` and `x-gts-traits` are **schema annotation keywords** and MUST only appear in JSON Schema documents (documents with `$schema`). They MUST NOT appear in instance documents. Implementations MUST reject instances that contain these keywords. +**Keyword placement:** Both `x-gts-traits-schema` and `x-gts-traits` are type-level keywords and MUST appear at the **top level** of the GTS Type Schema document, adjacent to `$id` and `$schema` — NOT nested inside an `allOf` entry or any other subschema. A misplaced occurrence MUST be rejected (fail fast). This governs only the position of the keyword itself, not the contents of `x-gts-traits-schema` (which is an ordinary JSON Schema subschema and may freely use `$ref`, `allOf`, etc.). The same rule applies to the modifiers `x-gts-final` / `x-gts-abstract` (§9.11). + A single schema MAY contain both keywords. This is explicitly allowed and useful when a mid-level schema defines new trait properties (`x-gts-traits-schema`) while also resolving traits inherited from its parent (`x-gts-traits`). **`x-gts-traits-schema`** is a JSON Schema [subschema](https://json-schema.org/learn/glossary#subschema). By the JSON Schema definition, its value MAY therefore be: @@ -1378,7 +1381,7 @@ See [`adr/0002-x-gts-traits-schema.md`](adr/0002-x-gts-traits-schema.md) for the A type schema declares the trait shape — property names, types, constraints, and `default` values. Any type in the `$id` chain (base or descendant) MAY contribute its own `x-gts-traits-schema`; the registry composes all such declarations along the chain via JSON Schema `allOf` into a single effective trait-schema (see §9.7.5). -The same derivation compatibility principle that governs host body schemas (§3.1) applies to `x-gts-traits-schema`: every value valid against the descendant's effective trait-schema MUST also be valid against each ancestor's trait-schema. This is enforced naturally by the `allOf` composition — contradictions across the chain (e.g., conflicting types, narrowed constraints that don't overlap, or different `default`s for the same property) produce an unsatisfiable effective trait-schema and fail registration. Typically a base declares the initial trait shape and descendants **narrow** existing trait properties (tighten constraints, `const`, narrower enums). Descendants MAY also **extend** the trait surface by introducing new top-level properties — but only if no ancestor's `x-gts-traits-schema` declares `additionalProperties: false` (or another restriction that would reject the new property); otherwise the new property is treated as "additional" against that ancestor's branch in the `allOf` composition and validation fails, by the same mechanic as §3.1 governs for host bodies. +The same derivation compatibility principle that governs host body schemas (§3.1) applies to `x-gts-traits-schema`: every value valid against the descendant's effective trait-schema MUST also be valid against each ancestor's trait-schema. This is enforced naturally by the `allOf` composition — contradictions across the chain (e.g., conflicting types, narrowed constraints that don't overlap) produce an unsatisfiable effective trait-schema and fail registration. Typically a base declares the initial trait shape and descendants **narrow** existing trait properties (tighten constraints, `const`, narrower enums). Descendants MAY also **extend** the trait surface by introducing new top-level properties — but only if no ancestor's `x-gts-traits-schema` declares `additionalProperties: false` (or another restriction that would reject the new property); otherwise the new property is treated as "additional" against that ancestor's branch in the `allOf` composition and validation fails, by the same mechanic as §3.1 governs for host bodies. `default` values are JSON Schema annotations and do not participate in narrowing: descendants MAY freely redeclare a property's `default` in their own `x-gts-traits-schema`. A publisher who wants a trait value to be fixed across descendants SHOULD declare `"const": ` (a real narrowing of the validation surface), not rely on a default. **Inline definition:** @@ -1448,18 +1451,18 @@ Where each referenced trait schema is a standalone JSON Schema registered as a G Derived schemas **resolve** (configure) trait values by providing a plain JSON object via `x-gts-traits`. Trait values MUST be valid against the effective trait schema derived from the inheritance chain as defined below. +`x-gts-traits` is a top-level member of the document (§9.7.1), a sibling of `$id` / `$schema` / `allOf` — not nested inside an `allOf` entry: + ```json { "$id": "gts://gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~", "allOf": [ - { "$ref": "gts://gts.x.core.events.type.v1~" }, - { - "x-gts-traits": { - "topicRef": "gts.x.core.events.topic.v1~x.commerce._.orders.v1", - "retention": "P90D" - } - } - ] + { "$ref": "gts://gts.x.core.events.type.v1~" } + ], + "x-gts-traits": { + "topicRef": "gts.x.core.events.topic.v1~x.commerce._.orders.v1", + "retention": "P90D" + } } ``` @@ -1467,27 +1470,27 @@ Derived schemas **resolve** (configure) trait values by providing a plain JSON o A mid-level schema MAY extend the trait schema while also providing values for inherited traits: +Both keywords sit at the top level alongside `$id` (§9.7.1); the `allOf` carries only the body composition: + ```json { "$id": "gts://gts.x.core.events.type.v1~x.core.audit.event.v1~", "allOf": [ - { "$ref": "gts://gts.x.core.events.type.v1~" }, - { - "x-gts-traits-schema": { - "type": "object", - "properties": { - "auditRetention": { - "description": "Retention override for audit compliance.", - "type": "string", - "default": "P365D" - } - } - }, - "x-gts-traits": { - "topicRef": "gts.x.core.events.topic.v1~x.core._.audit.v1" + { "$ref": "gts://gts.x.core.events.type.v1~" } + ], + "x-gts-traits-schema": { + "type": "object", + "properties": { + "auditRetention": { + "description": "Retention override for audit compliance.", + "type": "string", + "default": "P365D" } } - ] + }, + "x-gts-traits": { + "topicRef": "gts.x.core.events.topic.v1~x.core._.audit.v1" + } } ``` @@ -1501,19 +1504,18 @@ Given an inheritance chain `S₀ → S₁ → … → Sₙ`: - The registry MUST build an *effective trait schema* by composing all encountered `x-gts-traits-schema` values using JSON Schema `allOf`. - Any `$ref` appearing inside `x-gts-traits-schema` MUST be resolved using standard JSON Schema `$ref` resolution rules (base URI resolution + JSON Pointer fragments). - Derived schemas MAY further constrain (narrow) traits by adding additional schema constraints in their `x-gts-traits-schema` (this is naturally enforced by `allOf`). - - **Immutable defaults:** `default` values declared in an ancestor's `x-gts-traits-schema` MUST NOT be changed by a descendant's `x-gts-traits-schema`. If a descendant redeclares a trait property with a different `default`, schema validation MUST fail. - **Trait value merge** - The registry MUST build an *effective traits object* by walking the type's `$id` chain root → leaf and applying each layer's `x-gts-traits` as a [JSON Merge Patch (RFC 7396)](https://datatracker.ietf.org/doc/html/rfc7396) against the chain-merged object so far. Top-level scalar / array / `null` leaves are overwritten by the descendant (last-wins). Object-valued top-level traits merge **recursively** — fields of an ancestor's object trait that the descendant does not restate are preserved. - **Arrays replace wholesale** at any depth (per RFC 7396). Authors who need item-level composability SHOULD model the data as a keyed object instead of an array. - - **`null` at any depth deletes that key** from the effective object (per RFC 7396). The principal use case is to revert an ancestor-set value and let the trait-schema's `default` re-apply via the materialization step described in the Completeness check below — that is, a descendant writes `"": null` to "fall back to the schema default" without picking a specific value. If the deleted key is `required` and has no `default`, the completeness check fails registration for non-abstract types (the descendant must then either mark itself abstract or accept that "delete + required + no default" is an unresolvable contract). Authors who want `null` as an *intended* trait value cannot express it via this merge and must use a sentinel value documented as part of the trait shape. + - **`null` at any depth deletes that key** from the effective object (per RFC 7396). The principal use case is to revert an ancestor-set value and let the trait-schema's `default` re-apply via the materialization step described in the Completeness check below — that is, a descendant writes `"": null` to "fall back to the schema default" without picking a specific value. If the deleted key is `required` and has no `default`, the completeness check (OP#13) fails for non-abstract types (the descendant must then either mark itself abstract or accept that "delete + required + no default" is an unresolvable contract). Authors who want `null` as an *intended* trait value cannot express it via this merge and must use a sentinel value documented as part of the trait shape. - Defaults declared in the effective trait-schema MUST be materialized into the effective traits object before the Completeness check runs (per ADR-0003): for every property declared in the effective trait-schema with a `default` and not present in the chain-merged object, the registry MUST substitute the default value. The Completeness check below (OP#13) operates on the resulting *materialized* effective traits object. - A publisher who wants a trait value to be **locked** across all descendants of a base type SHOULD declare `"const": ` for that property in `x-gts-traits-schema`. A descendant attempting to override the value will fail the standard JSON Schema validation that runs against the effective trait-schema (per the Completeness check below). No GTS-specific "immutability" rule is required — `const` is the mechanism. - A descendant MAY redeclare a trait value with the same value the ancestor already declared (idempotent restatement). - See [`adr/0004-x-gts-traits-merge-strategy.md`](adr/0004-x-gts-traits-merge-strategy.md) for the rationale. - **Validation** - - **Completeness check** (registration-time): For types whose `x-gts-abstract` is not `true`, the registry MUST verify that the *materialized* effective traits object validates against the effective trait-schema using standard JSON Schema validation. "Materialized" means: defaults declared in the effective trait-schema for properties not present in the chain-merged effective traits object are substituted in before validation. If validation fails — in particular, if a `required` property of the effective trait-schema has no chain-assigned value and no default — registration MUST fail. For types with `x-gts-abstract: true`, this completeness check is skipped; descendants are expected to close any unresolved required traits. See [`adr/0003-x-gts-traits-completeness.md`](adr/0003-x-gts-traits-completeness.md) for the rationale. + - **Completeness check** (OP#13, type-level): For types whose `x-gts-abstract` is not `true`, the registry MUST verify that the *materialized* effective traits object validates against the effective trait-schema using standard JSON Schema validation. "Materialized" means: defaults declared in the effective trait-schema for properties not present in the chain-merged effective traits object are substituted in before validation. If validation fails — in particular, if a `required` property of the effective trait-schema has no chain-assigned value and no default — the type fails OP#13 validation. Completeness is a property of the **type** itself, not of any instance: it is always enforced on the explicit validation endpoints (`/validate-type-schema`, `/validate-entity`), and is additionally enforced at registration **when validation is enabled** (`?validate=true`), per the common pattern described in §9.11.5. For types with `x-gts-abstract: true`, this completeness check is skipped; descendants are expected to close any unresolved required traits. See [`adr/0003-x-gts-traits-completeness.md`](adr/0003-x-gts-traits-completeness.md) for the rationale. - If the effective trait schema cannot be satisfied (e.g., contradictory constraints introduced across the chain), schema validation MUST fail. **Example — descendant override and `const` lock:** @@ -1580,7 +1582,7 @@ When a schema declares `"x-gts-final": true`: 4. **No propagation**: `x-gts-final` applies only to the schema that declares it. It does NOT propagate to base types in the chain. For a chain `A~ → B~ → C~`, if `B~` is final, then `C~` is invalid. But `A~` can still be inherited by types other than `B~`'s descendants. -5. **Keyword placement**: The keyword MUST appear at the **top level** of the JSON Schema document, adjacent to `$id` and `$schema`: +5. **Keyword placement**: The keyword MUST appear at the **top level** of the JSON Schema document, adjacent to `$id` and `$schema` — NOT nested inside an `allOf` entry or any other subschema. A misplaced occurrence MUST be rejected (fail fast). The same applies to `x-gts-abstract` (§9.11.3 item 6) and the trait keywords (§9.7.1). ```json { @@ -1598,12 +1600,12 @@ For derived schemas using `allOf`, the keyword MUST appear at the top level, NOT { "$id": "gts://gts.x.core.events.type.v1~x.vendor._.order_event.v1~", "$schema": "http://json-schema.org/draft-07/schema#", - "x-gts-final": true, "type": "object", "allOf": [ { "$ref": "gts://gts.x.core.events.type.v1~" }, { "..." : {} } - ] + ], + "x-gts-final": true } ``` @@ -1621,6 +1623,8 @@ When a schema declares `"x-gts-abstract": true`: 5. **Anonymous instances**: For combined anonymous instance IDs like `gts.A~`, the system resolves the type from the prefix. If that type is abstract, the instance MUST be rejected. +6. **Keyword placement**: Like `x-gts-final` (§9.11.2 item 5), `x-gts-abstract` is a type-level modifier and MUST appear at the **top level** of the JSON Schema document, adjacent to `$id` and `$schema` — NOT inside an `allOf` entry (or any other subschema). A subschema is not "the type"; placing the modifier there is a misplacement and MUST be rejected during schema registration or validation. + #### 9.11.4 Interaction with `x-gts-traits` - **Completeness keyed on `x-gts-abstract`**: A type whose `x-gts-abstract` is not `true` MUST satisfy trait completeness at registration (see §9.7.5). A type with `x-gts-abstract: true` is exempt — abstract types may have unresolved required traits; descendants are expected to close them. See [`adr/0003-x-gts-traits-completeness.md`](adr/0003-x-gts-traits-completeness.md). @@ -1711,7 +1715,7 @@ Result: ❌ NO MATCH (different major versions) ### 11.0 Relationship to JSON Schema -GTS Type Schemas **extend JSON Schema** with a vendor keyword set (`x-gts-*`) and a set of **registry-enforced semantic rules** (see §3.2 derivation, §9.11 modifiers, OP#12 derivation compatibility, OP#13 trait validation). GTS does **not** impose additional syntactic restrictions on otherwise-valid JSON Schemas: any syntactically valid JSON Schema that carries a valid GTS `$id` is a syntactically valid GTS Type Schema. Implementations MUST treat the GTS keywords described in this specification (`x-gts-traits-schema`, `x-gts-traits`, `x-gts-final`, `x-gts-abstract`, etc.) as layered on top of the underlying JSON Schema dialect's semantics, alongside the standard JSON Schema keywords (`$id`, `$ref`, `allOf`, `const`, …) used here. +GTS Type Schemas **extend JSON Schema** with a vendor keyword set (`x-gts-*`) and a set of **registry-enforced semantic rules** (see §3.2 derivation, §9.11 modifiers, OP#12 derivation compatibility, OP#13 trait validation). GTS does **not** impose additional syntactic restrictions on the standard JSON Schema body: any syntactically valid JSON Schema body that carries a valid GTS `$id` is a syntactically valid GTS Type Schema. The constraints GTS does enforce on document structure concern only its own `x-gts-*` keywords — these are type-level annotations that MUST appear at the document top level and are rejected when misplaced, and that MUST NOT appear in instance documents (§9.7.1, §9.11). Implementations MUST treat the GTS keywords described in this specification as layered on top of the underlying JSON Schema dialect's semantics, alongside the standard JSON Schema keywords (`$id`, `$ref`, `allOf`, `const`, …) used here. **Dialect-agnostic.** GTS does not pin Type Schemas to a single JSON Schema draft. The dialect of any concrete GTS Type Schema is set by its `$schema` URI, and implementations MUST honour that dialect when validating or interpreting the schema body. The reference examples in this specification declare `$schema: http://json-schema.org/draft-07/schema#` because Draft-07 has the broadest tooling support and is the safest baseline for cross-vendor interoperability; however, Type Schemas that declare a later dialect — Draft 2019-09 (`https://json-schema.org/draft/2019-09/schema`) or Draft 2020-12 (`https://json-schema.org/draft/2020-12/schema`) — are equally valid GTS Type Schemas. Authors who wish to use post-Draft-07 keywords (`$defs`, `prefixItems`, `unevaluatedProperties`, `unevaluatedItems`, `$dynamicRef`/`$dynamicAnchor`, `dependentRequired`, `dependentSchemas`, …) MAY do so, provided the dialect declared in `$schema` admits those keywords and the GTS-specific rules (derivation compatibility per OP#12, trait validation per OP#13, modifiers per §9.11) are satisfied. diff --git a/adr/0003-x-gts-traits-completeness.md b/adr/0003-x-gts-traits-completeness.md index 6cfcf9e..58e25ee 100644 --- a/adr/0003-x-gts-traits-completeness.md +++ b/adr/0003-x-gts-traits-completeness.md @@ -238,7 +238,7 @@ The completeness check applies if and only if the type's `x-gts-abstract` is not At the registration of type T: 1. Compute *effective trait-schema* = JSON Schema `allOf` composition of all `x-gts-traits-schema` declarations along T's `$id` chain (per ADR-0002). -2. Compute *effective traits object* = chain-merged `x-gts-traits` along T's `$id` chain (subject to the immutable-once-set rule for inherited values). +2. Compute *effective traits object* = chain-merged `x-gts-traits` along T's `$id` chain (per ADR-0004, RFC 7396 JSON Merge Patch root → leaf). 3. *Materialize* the effective traits object: for every property declared in the effective trait-schema with a `default` and no value in the merged object, substitute the default value. 4. If `T.x-gts-abstract` is `true`: skip the completeness check (other validations on T still run normally). 5. Otherwise: validate the materialized effective traits object against the effective trait-schema using standard JSON Schema validation. If validation fails (including `required` properties absent after materialization), registration MUST fail with an error citing the unresolved required properties. diff --git a/examples/events/types/gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~.schema.json b/examples/events/types/gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~.schema.json index 0a32dde..955b749 100644 --- a/examples/events/types/gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~.schema.json +++ b/examples/events/types/gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~.schema.json @@ -8,10 +8,6 @@ { "type": "object", "required": ["type", "payload", "subjectType"], - "x-gts-traits": { - "topicRef": "gts.x.core.events.topic.v1~x.commerce._.orders.v1", - "retention": "P90D" - }, "properties": { "type": { "const": "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.0~", @@ -33,5 +29,9 @@ } } } - ] + ], + "x-gts-traits": { + "topicRef": "gts.x.core.events.topic.v1~x.commerce._.orders.v1", + "retention": "P90D" + } } diff --git a/examples/events/types/gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.1~.schema.json b/examples/events/types/gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.1~.schema.json index aa5047a..905add1 100644 --- a/examples/events/types/gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.1~.schema.json +++ b/examples/events/types/gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.1~.schema.json @@ -8,10 +8,6 @@ { "type": "object", "required": ["type", "payload", "subjectType"], - "x-gts-traits": { - "topicRef": "gts.x.core.events.topic.v1~x.commerce._.orders.v1", - "retention": "P90D" - }, "properties": { "type": { "const": "gts.x.core.events.type.v1~x.commerce.orders.order_placed.v1.1~", @@ -34,5 +30,9 @@ } } } - ] + ], + "x-gts-traits": { + "topicRef": "gts.x.core.events.topic.v1~x.commerce._.orders.v1", + "retention": "P90D" + } } diff --git a/examples/events/types/gts.x.core.events.type.v1~x.core.idp.contact_created.v1~.schema.json b/examples/events/types/gts.x.core.events.type.v1~x.core.idp.contact_created.v1~.schema.json index f18f9d6..2cf5cbf 100644 --- a/examples/events/types/gts.x.core.events.type.v1~x.core.idp.contact_created.v1~.schema.json +++ b/examples/events/types/gts.x.core.events.type.v1~x.core.idp.contact_created.v1~.schema.json @@ -9,10 +9,6 @@ { "type": "object", "required": ["type", "payload", "subjectType"], - "x-gts-traits": { - "topicRef": "gts.x.core.events.topic.v1~x.core.idp.contacts.v1", - "retention": "P365D" - }, "properties": { "type": { "const": "gts.x.core.events.type.v1~x.core.idp.contact_created.v1.0~", @@ -35,5 +31,9 @@ } } } - ] + ], + "x-gts-traits": { + "topicRef": "gts.x.core.events.topic.v1~x.core.idp.contacts.v1", + "retention": "P365D" + } } From 081801d72dbbb8df0d2e537d31e3c236d08adc90 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Thu, 28 May 2026 16:57:57 +0300 Subject: [PATCH 6/7] test(spec): align suite with ADRs 0001-0004 and v0.12 default semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0001 (derivation form is dialect-agnostic): - Add register_derived_redeclared helper for tests that author derived schemas without allOf (chained $id alone establishes derivation). - op12: add Redeclared_* cases covering compatible re-declaration, type tightening, constraint loosening, dropping parent's required, AP:false tightening, final-base rejection by $id chain, and the hybrid allOf+toplevel form. - Refresh "x-gts-{final,abstract} inside allOf rejected" docstrings to cite ADR-0001 explicitly. ADR-0002 (x-gts-traits-schema as a JSON Schema subschema): - Drop TraitsSchemaNotObject (object/true/false are all admissible under the new value space); replace with TraitsValueViolatesInteger Schema covering value-level failure against an integer subschema. - Add cases for chain allOf aggregation, descendant-omits-decl, descendant-adds-new-field, boolean true/false (positive and negative), incompatible descendant schema, redundant ancestor ref, object form regression, standalone ref-pattern + 3-level chain. - Rework CyclingRef_SelfRef into a TRUE self-cycle (the prior shape — two identical $refs inside allOf — is allowed in v0.12). ADR-0003 (trait completeness keyed on x-gts-abstract): - Add register_abstract helper. - Add cases: abstract type skips completeness with unresolved required, non-abstract intermediate must be complete, abstract base + concrete descendant satisfies, default satisfies required, null-delete on required-no-default fails for non-abstract. - Update interaction tests' docstrings (final-with-traits-resolved, final-with-traits-missing, abstract-with-incomplete-traits-ok) to cite ADR-0003. ADR-0004 (x-gts-traits as RFC 7396 JSON Merge Patch): - Remove the three "override MUST fail" classes: TraitsInvalid_OverrideInChain and OverrideTopicRef3Level (value overrides are now last-wins); ChangeDefaultInMid (trait-schema default redeclaration is allowed per the relaxed §9.7.5 rule). - Add Merge_* cases: scalar override last-wins, 3-layer last-wins, nested object recursive merge, array wholesale replacement (positive + negative proving no element-merge), null-delete falls back to default, null-delete + abstract descendant OK, const lock rejects override, const lock idempotent restatement, idempotent scalar restatement. Positive coverage for the relaxed default rule: - op12: Redeclared_ChangePropertyDefaultAllowed — derived may freely redeclare `properties..default` (annotation, not a constraint). - op13: TraitsSchema_RedeclareDefaultAllowed — descendant may freely redeclare a trait-schema `default`; effective default at materialization time is the leaf-most one along the chain. Signed-off-by: Aviator 5 --- tests/helpers/http_run_helpers.py | 65 + tests/test_op12_type_derivation_validation.py | 631 ++++ tests/test_op13_schema_traits_validation.py | 2653 +++++++++++++++-- tests/test_refimpl_x_gts_final_abstract.py | 30 +- tests/test_xgts_keyword_placement.py | 327 ++ 5 files changed, 3434 insertions(+), 272 deletions(-) create mode 100644 tests/test_xgts_keyword_placement.py diff --git a/tests/helpers/http_run_helpers.py b/tests/helpers/http_run_helpers.py index 1a5e2ab..25aec6a 100644 --- a/tests/helpers/http_run_helpers.py +++ b/tests/helpers/http_run_helpers.py @@ -28,7 +28,19 @@ def register_derived(gts_id, base_ref, overlay, label="register derived", top_le top_level: optional dict of extra keys to add at schema top level (e.g. {"x-gts-final": True}) — these MUST NOT go inside allOf. + + The document-level GTS keywords x-gts-traits / x-gts-traits-schema MUST + appear at the schema top level, not nested inside an allOf entry + (GTS spec §9.12). If a caller places them in the `overlay`, they are + transparently hoisted to the top level so existing trait tests express + the spec-correct placement without restating every call site. """ + overlay = dict(overlay) + hoisted = { + kw: overlay.pop(kw) + for kw in ("x-gts-traits", "x-gts-traits-schema") + if kw in overlay + } body = { "$$id": gts_id, "$$schema": "http://json-schema.org/draft-07/schema#", @@ -38,6 +50,7 @@ def register_derived(gts_id, base_ref, overlay, label="register derived", top_le overlay, ], } + body.update(hoisted) if top_level: body.update(top_level) return Step( @@ -49,6 +62,58 @@ def register_derived(gts_id, base_ref, overlay, label="register derived", top_le ) +def register_derived_redeclared( + gts_id, base_ref, body, label="register derived (no allOf)", top_level=None +): + """Register a derived schema without allOf — caller restates parent fields directly. + + Per ADR-0001 (GTS as a JSON Schema extension, dialect-agnostic; not a formal + JSON Schema Dialect), derivation is established by the chained $id alone; + the body MAY use any syntactically valid JSON Schema form. + `base_ref` is accepted for parity with register_derived() and documents intent. + + `body` is the entire schema body (caller is responsible for restating any + parent fields that need to participate in OP#12 compatibility). The helper + only injects $id and $schema; no allOf wrapping is added. + top_level: optional dict merged into body at the top level. + """ + full = { + "$$id": gts_id, + "$$schema": "http://json-schema.org/draft-07/schema#", + **body, + } + if top_level: + full.update(top_level) + return Step( + RunRequest(label) + .post("/entities") + .with_json(full) + .validate() + .assert_equal("status_code", 200) + ) + + +def register_abstract(gts_id, schema_body, label="register abstract"): + """Register a schema marked x-gts-abstract: true. + + Per ADR-0003, abstract types skip the trait-completeness check at + /validate-type-schema time. + """ + body = { + "$$id": gts_id, + "$$schema": "http://json-schema.org/draft-07/schema#", + **schema_body, + "x-gts-abstract": True, + } + return Step( + RunRequest(label) + .post("/entities") + .with_json(body) + .validate() + .assert_equal("status_code", 200) + ) + + def register_instance(instance_body, label="register instance"): """Register an instance via POST /entities.""" return Step( diff --git a/tests/test_op12_type_derivation_validation.py b/tests/test_op12_type_derivation_validation.py index fd75821..e6002cd 100644 --- a/tests/test_op12_type_derivation_validation.py +++ b/tests/test_op12_type_derivation_validation.py @@ -2,6 +2,7 @@ from .helpers.http_run_helpers import ( register as _register, register_derived as _register_derived, + register_derived_redeclared as _register_derived_redeclared, validate_type_schema as _validate_type_schema, ) from httprunner import HttpRunner, Config, Step, RunRequest @@ -3523,5 +3524,635 @@ def test_start(self): ] +# --------------------------------------------------------------------------- +# ADR-0001: derivation form +# +# GTS is a JSON Schema extension, dialect-agnostic (not a formal JSON Schema +# Dialect — it ships no dedicated $schema URI or meta-schema). The dialect of +# any concrete Type Schema is what its own $schema declares (Draft-07 is the +# example baseline; Draft 2019-09 and 2020-12 are equally supported). +# +# OP#12 compatibility applies to derived schemas regardless of whether they +# use allOf + $ref or re-declare parent fields directly. x-gts-final is +# enforced from the chained $id alone, not from body shape. +# --------------------------------------------------------------------------- + + +class TestCaseOp12_Redeclared_CompatiblePasses(HttpRunner): + """ADR-0001: derived schema without allOf, re-declaring parent fields. + + Compatible re-declaration plus an added optional field. Passes OP#12. + """ + + config = Config("OP#12 ADR-0001: redeclared compatible passes").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test12.redcmp.user.v1~", + { + "type": "object", + "required": ["userId", "name"], + "properties": { + "userId": {"type": "string", "format": "uuid"}, + "name": {"type": "string", "maxLength": 100}, + }, + }, + "register base user", + ), + _register_derived_redeclared( + "gts://gts.x.test12.redcmp.user.v1~x.test12._.premium.v1~", + "gts://gts.x.test12.redcmp.user.v1~", + { + "type": "object", + "required": ["userId", "name"], + "properties": { + "userId": {"type": "string", "format": "uuid"}, + "name": {"type": "string", "maxLength": 100}, + "tier": {"type": "string"}, + }, + }, + "register derived without allOf", + ), + _validate_type_schema( + "gts.x.test12.redcmp.user.v1~x.test12._.premium.v1~", + True, + "validate redeclared derived - compatible", + ), + ] + + +class TestCaseOp12_Redeclared_TypeTighteningPasses(HttpRunner): + """ADR-0001: redeclared derived narrows email format. Valid OP#12 tightening.""" + + config = Config("OP#12 ADR-0001: redeclared tightening passes").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test12.redtight.user.v1~", + { + "type": "object", + "required": ["userId", "contact"], + "properties": { + "userId": {"type": "string"}, + "contact": {"type": "string"}, + }, + }, + "register base user", + ), + _register_derived_redeclared( + "gts://gts.x.test12.redtight.user.v1~x.test12._.emailed.v1~", + "gts://gts.x.test12.redtight.user.v1~", + { + "type": "object", + "required": ["userId", "contact"], + "properties": { + "userId": {"type": "string"}, + "contact": {"type": "string", "format": "email"}, + }, + }, + "register derived - tightens contact to format:email", + ), + _validate_type_schema( + "gts.x.test12.redtight.user.v1~x.test12._.emailed.v1~", + True, + "validate redeclared derived - tightening allowed", + ), + ] + + +class TestCaseOp12_Redeclared_ConstraintViolationFails(HttpRunner): + """ADR-0001: redeclared derived loosens maxLength. Must fail OP#12.""" + + config = Config("OP#12 ADR-0001: redeclared loosening fails").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test12.redloose.user.v1~", + { + "type": "object", + "required": ["userId", "name"], + "properties": { + "userId": {"type": "string"}, + "name": {"type": "string", "maxLength": 64}, + }, + }, + "register base with name maxLength=64", + ), + _register_derived_redeclared( + "gts://gts.x.test12.redloose.user.v1~x.test12._.wider.v1~", + "gts://gts.x.test12.redloose.user.v1~", + { + "type": "object", + "required": ["userId", "name"], + "properties": { + "userId": {"type": "string"}, + "name": {"type": "string", "maxLength": 256}, + }, + }, + "register derived loosens name maxLength to 256", + ), + _validate_type_schema( + "gts.x.test12.redloose.user.v1~x.test12._.wider.v1~", + False, + "validate redeclared derived - loosening rejected", + ), + ] + + +class TestCaseOp12_Redeclared_DropsParentRequiredFails(HttpRunner): + """ADR-0001: redeclared derived omits parent's required field. Must fail OP#12.""" + + config = Config("OP#12 ADR-0001: redeclared drops required").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test12.redreq.user.v1~", + { + "type": "object", + "required": ["userId", "email"], + "properties": { + "userId": {"type": "string"}, + "email": {"type": "string"}, + }, + }, + "register base requiring userId and email", + ), + _register_derived_redeclared( + "gts://gts.x.test12.redreq.user.v1~x.test12._.lax.v1~", + "gts://gts.x.test12.redreq.user.v1~", + { + "type": "object", + "required": ["userId"], + "properties": { + "userId": {"type": "string"}, + "email": {"type": "string"}, + }, + }, + "register derived dropping email from required", + ), + _validate_type_schema( + "gts.x.test12.redreq.user.v1~x.test12._.lax.v1~", + False, + "validate redeclared derived - dropped required field", + ), + ] + + +class TestCaseOp12_Redeclared_AddsAPFalseTightening_Passes(HttpRunner): + """ADR-0001: redeclared derived tightens by adding additionalProperties:false. + + OP#12 requires every valid instance of the derived schema to be valid in + the base — i.e. derived may tighten, never loosen. Closing an open base + with additionalProperties:false in the derived is a valid tightening and + MUST pass. (This replaces an earlier mis-stated case where the assertion + was backwards.) + """ + + config = Config("OP#12 ADR-0001: redeclared AP:false tightening").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test12.redapf.user.v1~", + { + "type": "object", + "required": ["userId"], + "properties": { + "userId": {"type": "string"}, + "email": {"type": "string"}, + }, + }, + "register open base", + ), + _register_derived_redeclared( + "gts://gts.x.test12.redapf.user.v1~x.test12._.closed.v1~", + "gts://gts.x.test12.redapf.user.v1~", + { + "type": "object", + "additionalProperties": False, + "required": ["userId"], + "properties": { + "userId": {"type": "string"}, + "email": {"type": "string"}, + }, + }, + "register redeclared derived closing open base with AP:false", + ), + _validate_type_schema( + "gts.x.test12.redapf.user.v1~x.test12._.closed.v1~", + True, + "validate redeclared derived - tightening allowed", + ), + ] + + +class TestCaseOp12_FinalBase_RedeclaredDerivationStillRejected(HttpRunner): + """ADR-0001: x-gts-final enforced from $id chain alone, not body shape. + + Base is final. A derived schema authored WITHOUT allOf (re-declaration + form) and otherwise semantically compatible MUST still be rejected, + because finality is keyed on the chained $id. + """ + + config = Config("OP#12 ADR-0001: final base rejects redeclared derivation").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test12.redfinb.user.v1~", + { + "type": "object", + "x-gts-final": True, + "required": ["userId"], + "properties": { + "userId": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + "register final base", + ), + _register_derived_redeclared( + "gts://gts.x.test12.redfinb.user.v1~x.test12._.kid.v1~", + "gts://gts.x.test12.redfinb.user.v1~", + { + "type": "object", + "required": ["userId"], + "properties": { + "userId": {"type": "string"}, + "name": {"type": "string"}, + "nickname": {"type": "string"}, + }, + }, + "register redeclared derived from final base (no allOf in body)", + ), + _validate_type_schema( + "gts.x.test12.redfinb.user.v1~x.test12._.kid.v1~", + False, + "validate derived from final base - rejected by $id-chain finality", + ), + ] + + +class TestCaseOp12_Redeclared_HybridAllOfPlusToplevel(HttpRunner): + """ADR-0001: hybrid form — allOf:[{$ref:parent}] AND top-level properties. + + Demonstrates the extension-framing principle: derivation forms are not + syntactically restricted. A compatible hybrid form must pass OP#12. + """ + + config = Config("OP#12 ADR-0001: hybrid allOf + toplevel passes").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test12.redhyb.user.v1~", + { + "type": "object", + "required": ["userId"], + "properties": { + "userId": {"type": "string"}, + "name": {"type": "string"}, + }, + }, + "register base user", + ), + Step( + RunRequest("register hybrid derived (allOf + top-level properties)") + .post("/entities") + .with_json({ + "$$id": ( + "gts://gts.x.test12.redhyb.user.v1~" + "x.test12._.hybrid.v1~" + ), + "$$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "allOf": [ + {"$$ref": "gts://gts.x.test12.redhyb.user.v1~"}, + ], + "properties": { + "tier": {"type": "string"}, + }, + }) + .validate() + .assert_equal("status_code", 200) + ), + _validate_type_schema( + "gts.x.test12.redhyb.user.v1~x.test12._.hybrid.v1~", + True, + "validate hybrid derived - passes", + ), + ] + + +class TestCaseOp12_Redeclared_ChangePropertyDefaultAllowed(HttpRunner): + """§3.1 / OP#12: derived may redeclare a property's `default`. + + Narrowing is defined over the set of valid instances. `default` is a + JSON Schema annotation and does not participate in validation, so + changing it neither tightens nor loosens the validation surface. A + derived type MAY redeclare `properties..default` freely. + + Base declares `tier.default = "free"`. Derived redeclares + `tier.default = "gold"` (with no other changes that would violate + OP#12). Validation MUST pass. + """ + + config = Config("OP#12: redeclare property default allowed").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test12.pdfl.user.v1~", + { + "type": "object", + "required": ["userId"], + "properties": { + "userId": {"type": "string"}, + "tier": {"type": "string", "default": "free"}, + }, + }, + "register base with tier default=free", + ), + _register_derived_redeclared( + "gts://gts.x.test12.pdfl.user.v1~x.test12._.premium.v1~", + "gts://gts.x.test12.pdfl.user.v1~", + { + "type": "object", + "required": ["userId"], + "properties": { + "userId": {"type": "string"}, + "tier": {"type": "string", "default": "gold"}, + }, + }, + "register derived redeclaring tier default=gold", + ), + _validate_type_schema( + "gts.x.test12.pdfl.user.v1~x.test12._.premium.v1~", + True, + "validate - default redeclaration does not violate OP#12", + ), + ] + + +# --------------------------------------------------------------------------- +# v0.12 changelog: "the prior $defs MUST NOT and post-Draft-07-keyword +# restrictions are dropped". README §11.0 (Relationship to JSON Schema) +# frames GTS as dialect-agnostic; the §9.5 note on $ref now spells out: +# "definitions for Draft-07, $defs for Draft 2019-09 and later; both are +# admissible in GTS Type Schemas." This test exercises the dropped +# restriction directly via a Draft 2019-09 base that defines reusable +# subschemas in $defs and references them locally. +# --------------------------------------------------------------------------- + + +class TestCaseOp12_Dialect_2019_09_DefsAllowed(HttpRunner): + """v0.12 / ADR-0001: Draft 2019-09 with $defs is a valid GTS Type Schema. + + Before v0.12 the spec forbade $defs and post-Draft-07 keywords. ADR-0001 + reframes GTS as dialect-agnostic; the README changelog calls this out as + a breaking change. Register a base under Draft 2019-09 that uses $defs + for a reusable subschema; both registration and validation must succeed. + """ + + config = Config("OP#12 v0.12: Draft 2019-09 with $$defs allowed").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + Step( + RunRequest("register Draft 2019-09 base using $$defs") + .post("/entities") + .with_json({ + "$$id": "gts://gts.x.test12.dialect09.user.v1~", + "$$schema": "https://json-schema.org/draft/2019-09/schema", + "type": "object", + "$$defs": { + "NonEmptyString": {"type": "string", "minLength": 1}, + }, + "required": ["userId", "name"], + "properties": { + "userId": {"$$ref": "#/$$defs/NonEmptyString"}, + "name": {"$$ref": "#/$$defs/NonEmptyString"}, + }, + }) + .validate() + .assert_equal("status_code", 200) + ), + _validate_type_schema( + "gts.x.test12.dialect09.user.v1~", + True, + "validate Draft 2019-09 base - $$defs admissible", + ), + ] + + +class TestCaseOp12_Dialect_2020_12_PrefixItemsAllowed(HttpRunner): + """v0.12 / ADR-0001: Draft 2020-12 with prefixItems is a valid GTS Type Schema. + + ADR-0001 reframes GTS as dialect-agnostic; README §11.0 lists Draft 2020-12 + as equally valid and prefixItems among the admissible post-Draft-07 keywords. + Register a base under Draft 2020-12 that uses prefixItems; both registration + and validation must succeed. (Complements the Draft 2019-09 / $defs case.) + """ + + config = Config("OP#12 v0.12: Draft 2020-12 with prefixItems allowed").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + Step( + RunRequest("register Draft 2020-12 base using prefixItems") + .post("/entities") + .with_json({ + "$$id": "gts://gts.x.test12.dialect20.coord.v1~", + "$$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["point"], + "properties": { + "point": { + "type": "array", + "prefixItems": [ + {"type": "number"}, + {"type": "number"}, + ], + "items": False, + }, + }, + }) + .validate() + .assert_equal("status_code", 200) + ), + _validate_type_schema( + "gts.x.test12.dialect20.coord.v1~", + True, + "validate Draft 2020-12 base - prefixItems admissible", + ), + ] + + +class TestCaseOp12_Redeclared_MixedDialectChain(HttpRunner): + """ADR-0001: per-schema $schema — a Draft-07 base with a Draft 2019-09 derived. + + GTS pins no single draft; each schema's dialect is set by its own $schema + (README §11.0). The derived re-declares the parent's fields (no allOf) and + only tightens (maxLength 100 → 50), so OP#12 compatibility holds across the + dialect boundary. Validation passes. + """ + + config = Config( + "OP#12 ADR-0001: mixed-dialect chain (07 base, 2019-09 derived)" + ).base_url(get_gts_base_url()) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test12.mixdia.user.v1~", + { + "type": "object", + "required": ["userId"], + "properties": { + "userId": {"type": "string"}, + "name": {"type": "string", "maxLength": 100}, + }, + }, + "register Draft-07 base", + ), + Step( + RunRequest("register Draft 2019-09 derived re-declaring + tightening") + .post("/entities") + .with_json({ + "$$id": "gts://gts.x.test12.mixdia.user.v1~x.test12._.premium.v1~", + "$$schema": "https://json-schema.org/draft/2019-09/schema", + "type": "object", + "required": ["userId"], + "properties": { + "userId": {"type": "string"}, + "name": {"type": "string", "maxLength": 50}, + }, + }) + .validate() + .assert_equal("status_code", 200) + ), + _validate_type_schema( + "gts.x.test12.mixdia.user.v1~x.test12._.premium.v1~", + True, + "validate mixed-dialect derived - tightening across dialect boundary", + ), + ] + + +class TestCaseOp12_Redeclared_ThreeLevelChainCompatible(HttpRunner): + """ADR-0001 / §3.2: 3-level chain A~B~C re-declared without allOf. + + Each level restates the parent's fields directly (no allOf) and tightens + `maxLength` monotonically (100 → 80 → 50). OP#12 must hold transitively + across the whole multi-level hierarchy (README OP#12 line ~1298). The leaf + validates. + """ + + config = Config("OP#12 ADR-0001: 3-level redeclared chain compatible").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test12.red3lvl.user.v1~", + { + "type": "object", + "required": ["userId", "name"], + "properties": { + "userId": {"type": "string"}, + "name": {"type": "string", "maxLength": 100}, + }, + }, + "register root A (maxLength 100)", + ), + _register_derived_redeclared( + "gts://gts.x.test12.red3lvl.user.v1~x.test12._.mid.v1~", + "gts://gts.x.test12.red3lvl.user.v1~", + { + "type": "object", + "required": ["userId", "name"], + "properties": { + "userId": {"type": "string"}, + "name": {"type": "string", "maxLength": 80}, + }, + }, + "register mid B without allOf (maxLength 80)", + ), + _register_derived_redeclared( + ( + "gts://gts.x.test12.red3lvl.user.v1~" + "x.test12._.mid.v1~x.test12._.leaf.v1~" + ), + "gts://gts.x.test12.red3lvl.user.v1~x.test12._.mid.v1~", + { + "type": "object", + "required": ["userId", "name"], + "properties": { + "userId": {"type": "string"}, + "name": {"type": "string", "maxLength": 50}, + }, + }, + "register leaf C without allOf (maxLength 50)", + ), + _validate_type_schema( + ( + "gts.x.test12.red3lvl.user.v1~" + "x.test12._.mid.v1~x.test12._.leaf.v1~" + ), + True, + "validate 3-level redeclared leaf - transitively compatible", + ), + ] + + if __name__ == "__main__": TestCaseTestOp12TypeDerivationValidation_DerivedSchemaFullyMatches().test_start() diff --git a/tests/test_op13_schema_traits_validation.py b/tests/test_op13_schema_traits_validation.py index 2090f9a..c2d85bd 100644 --- a/tests/test_op13_schema_traits_validation.py +++ b/tests/test_op13_schema_traits_validation.py @@ -2,11 +2,16 @@ from .helpers.http_run_helpers import ( register as _register, register_derived as _register_derived, + register_abstract as _register_abstract, validate_entity as _validate_entity, validate_type_schema as _validate_type_schema, ) from httprunner import HttpRunner, Config +# Note (v0.12): ADR-0003 keys trait-completeness on x-gts-abstract (not "leaf"). +# Refimpls remain permissive at POST /entities — completeness is verified at +# POST /validate-type-schema. New ADR-0003/0004 cases below follow that pattern. + # --------------------------------------------------------------------------- # Tests @@ -159,13 +164,14 @@ def test_start(self): "default": "P30D", }, }, + "required": ["topicRef"], }, "required": ["id"], "properties": { "id": {"type": "string"}, }, }, - "register base with one trait without default", + "register base with one required trait without default", ), _register_derived( "gts://gts.x.test13.miss.event.v1~x.test13._.incomplete.v1~", @@ -498,9 +504,10 @@ def test_start(self): "description": "No default - must be resolved", }, }, + "required": ["priority"], }, }, - "register mid-level adding priority trait (no default)", + "register mid-level adding required priority trait (no default)", ), _register_derived( ( @@ -529,183 +536,39 @@ def test_start(self): ] -class TestCaseOp13_TraitsInvalid_OverrideInChain(HttpRunner): - """OP#13 - Traits: descendant cannot override ancestor trait value.""" - config = Config("OP#13 - Override In Chain").base_url(get_gts_base_url()) - - def test_start(self): - super().test_start() - - teststeps = [ - _register( - "gts://gts.x.test13.ovr.event.v1~", - { - "type": "object", - "x-gts-traits-schema": { - "type": "object", - "properties": { - "retention": { - "type": "string", - }, - }, - }, - "required": ["id"], - "properties": { - "id": {"type": "string"}, - }, - }, - "register base", - ), - _register_derived( - "gts://gts.x.test13.ovr.event.v1~x.test13._.mid_ovr.v1~", - "gts://gts.x.test13.ovr.event.v1~", - { - "type": "object", - "x-gts-traits": { - "retention": "P30D", - }, - }, - "register mid-level setting retention=P30D", - ), - _validate_type_schema( - "gts.x.test13.ovr.event.v1~x.test13._.mid_ovr.v1~", - True, - "validate mid-level", - ), - _register_derived( - ( - "gts://gts.x.test13.ovr.event.v1~" - "x.test13._.mid_ovr.v1~" - "x.test13._.leaf_ovr.v1~" - ), - "gts://gts.x.test13.ovr.event.v1~x.test13._.mid_ovr.v1~", - { - "type": "object", - "x-gts-traits": { - "retention": "P365D", - }, - }, - "register leaf overriding retention=P365D", - ), - _validate_type_schema( - ( - "gts.x.test13.ovr.event.v1~" - "x.test13._.mid_ovr.v1~" - "x.test13._.leaf_ovr.v1~" - ), - False, - "validate should fail - trait override not allowed", - ), - ] - - -class TestCaseOp13_TraitsInvalid_OverrideTopicRef3Level(HttpRunner): - """OP#13 - Traits: 3-level chain, leaf overrides topicRef. - - Mid-level sets topicRef to audit topic, leaf tries notification - topic. Validation must fail (immutable-once-set). - """ - config = Config( - "OP#13 - Override TopicRef 3-Level" - ).base_url(get_gts_base_url()) - - def test_start(self): - super().test_start() - - teststeps = [ - _register( - "gts://gts.x.test13.ovt.event.v1~", - { - "type": "object", - "x-gts-traits-schema": { - "type": "object", - "properties": { - "topicRef": { - "type": "string", - "x-gts-ref": ( - "gts.x.core.events.topic.v1~" - ), - }, - "retention": { - "type": "string", - "default": "P30D", - }, - }, - }, - "required": ["id"], - "properties": { - "id": {"type": "string"}, - }, - }, - "register base with topicRef + retention traits", - ), - _register_derived( - ( - "gts://gts.x.test13.ovt.event.v1~" - "x.test13._.audit_evt.v1~" - ), - "gts://gts.x.test13.ovt.event.v1~", - { - "type": "object", - "x-gts-traits": { - "topicRef": ( - "gts.x.core.events.topic.v1~" - "x.core._.audit.v1" - ), - }, - }, - "register mid-level setting topicRef=audit", - ), - _validate_type_schema( - ( - "gts.x.test13.ovt.event.v1~" - "x.test13._.audit_evt.v1~" - ), - True, - "validate mid-level - topicRef set", - ), - _register_derived( - ( - "gts://gts.x.test13.ovt.event.v1~" - "x.test13._.audit_evt.v1~" - "x.test13._.most_derived.v1~" - ), - ( - "gts://gts.x.test13.ovt.event.v1~" - "x.test13._.audit_evt.v1~" - ), - { - "type": "object", - "x-gts-traits": { - "topicRef": ( - "gts.x.core.events.topic.v1~" - "x.core._.notification.v1" - ), - }, - }, - "register leaf overriding topicRef=notification", - ), - _validate_type_schema( - ( - "gts.x.test13.ovt.event.v1~" - "x.test13._.audit_evt.v1~" - "x.test13._.most_derived.v1~" - ), - False, - "validate should fail - topicRef override not allowed", - ), - ] - - -class TestCaseOp13_TraitsInvalid_ChangeDefaultInMid(HttpRunner): - """OP#13 - Traits: mid-level changes default set by base. - - Base sets retention default=P30D. Mid-level redeclares - retention default=P90D. Validation must fail - defaults - set by ancestor are immutable. +# NOTE (v0.12): three classes that asserted "MUST fail on override" were +# removed: +# - TraitsInvalid_OverrideInChain (value override) +# - TraitsInvalid_OverrideTopicRef3Level (value override) +# - TraitsInvalid_ChangeDefaultInMid (default override in trait-schema) +# +# Rationale: ADR-0004 adopts RFC 7396 JSON Merge Patch for trait VALUES +# (descendant last-wins, no GTS-specific immutability). The same extension +# narrative — "narrowing is about validation surface; rely on standard JSON +# Schema; lock with `const`" — applies to trait-schema `default`s: defaults +# are annotations and do not participate in narrowing (see §9.7.5). A +# descendant MAY redeclare a property's `default`; the effective default at +# materialization time is the leaf-most one along the chain. To lock a value +# across descendants, use `const`. +# +# Replacement coverage: +# - Value overrides: TestCaseOp13_Merge_* below. +# - Default redeclaration: TestCaseOp13_TraitsSchema_RedeclareDefaultAllowed +# below, paired with the existing TestCaseOp13_Merge_ConstLock_* tests for +# the `const`-based locking pattern. + + +class TestCaseOp13_TraitsSchema_RedeclareDefaultAllowed(HttpRunner): + """ADR-0002 / §9.7.5: descendant redeclares a trait-schema `default`. + + Base declares `retention.default = "P30D"`. Mid-level descendant + redeclares `retention.default = "P90D"`. Neither layer supplies a value + in `x-gts-traits`. Validation MUST pass — defaults are annotations and + do not participate in narrowing. At materialization, the leaf-most + declared default ("P90D") fills the absent key. """ config = Config( - "OP#13 - Change Default In Mid-Level" + "OP#13 ADR-0002: redeclare trait-schema default allowed" ).base_url(get_gts_base_url()) def test_start(self): @@ -713,7 +576,7 @@ def test_start(self): teststeps = [ _register( - "gts://gts.x.test13.chdfl.event.v1~", + "gts://gts.x.test13.rdfl.event.v1~", { "type": "object", "x-gts-traits-schema": { @@ -723,10 +586,8 @@ def test_start(self): "type": "string", "default": "P30D", }, - "topicRef": { - "type": "string", - }, }, + "required": ["retention"], }, "required": ["id"], "properties": { @@ -737,10 +598,10 @@ def test_start(self): ), _register_derived( ( - "gts://gts.x.test13.chdfl.event.v1~" - "x.test13._.chdfl_mid.v1~" + "gts://gts.x.test13.rdfl.event.v1~" + "x.test13._.rdfl_mid.v1~" ), - "gts://gts.x.test13.chdfl.event.v1~", + "gts://gts.x.test13.rdfl.event.v1~", { "type": "object", "x-gts-traits-schema": { @@ -752,22 +613,16 @@ def test_start(self): }, }, }, - "x-gts-traits": { - "topicRef": ( - "gts.x.core.events.topic.v1~" - "x.test13._.orders.v1" - ), - }, }, - "register mid changing retention default to P90D", + "register mid redeclaring retention default to P90D", ), _validate_type_schema( ( - "gts.x.test13.chdfl.event.v1~" - "x.test13._.chdfl_mid.v1~" + "gts.x.test13.rdfl.event.v1~" + "x.test13._.rdfl_mid.v1~" ), - False, - "validate should fail - default override not allowed", + True, + "validate - default redeclaration is allowed", ), ] @@ -915,13 +770,14 @@ def test_start(self): "type": "string", }, }, + "required": ["topicRef", "retention"], }, "required": ["id"], "properties": { "id": {"type": "string"}, }, }, - "register base (no defaults)", + "register base (no defaults, both traits required)", ), _register_derived( "gts://gts.x.test13.entm.event.v1~x.test13._.bad_ent.v1~", @@ -1176,6 +1032,7 @@ def test_start(self): ), }, }, + "required": ["topicRef"], }, "register standalone TopicTrait schema", ), @@ -1480,7 +1337,12 @@ def test_start(self): class TestCaseOp13_TraitsInvalid_APBlocksExtension(HttpRunner): - """OP#13 - Traits: base additionalProperties=false blocks extension.""" + """OP#13 - Traits: ancestor additionalProperties=false blocks new fields. + + Under ADR-0002 chain aggregation, the effective trait-schema is allOf of + all ancestor declarations. The ancestor's additionalProperties:false is + carried into the aggregated allOf and rejects the descendant's new field. + """ config = Config( "OP#13 - Traits additionalProperties Blocks Extension" ).base_url(get_gts_base_url()) @@ -1754,7 +1616,13 @@ def test_start(self): class TestCaseOp13_TraitsInvalid_CyclingRef_SelfRef(HttpRunner): - """OP#13 - Traits: x-gts-traits-schema refs itself.""" + """OP#13 - Traits: trait-schema body $refs itself — true self-cycle. + + Under v0.12 ADR-0002, multiple independent occurrences of the same $ref + in an allOf are allowed (redundant manual aggregation). What MUST still + fail is a TRUE cycle where a trait-schema's body references its own ID, + causing infinite resolution. This case sets up exactly that. + """ config = Config( "OP#13 - Traits Self-Referencing Ref" ).base_url(get_gts_base_url()) @@ -1767,41 +1635,38 @@ def test_start(self): "gts://gts.x.test13.cyc.selfref.v1~", { "type": "object", + "allOf": [ + { + "$$ref": ( + "gts://gts.x.test13" + ".cyc.selfref.v1~" + ), + }, + ], "properties": { "retention": { "type": "string", }, }, }, - "register standalone trait schema", + "register trait schema that $refs itself", ), _register( "gts://gts.x.test13.cyc.selfevt.v1~", { "type": "object", "x-gts-traits-schema": { - "type": "object", - "allOf": [ - { - "$$ref": ( - "gts://gts.x.test13" - ".cyc.selfref.v1~" - ), - }, - { - "$$ref": ( - "gts://gts.x.test13" - ".cyc.selfref.v1~" - ), - }, - ], + "$$ref": ( + "gts://gts.x.test13" + ".cyc.selfref.v1~" + ), }, "required": ["id"], "properties": { "id": {"type": "string"}, }, }, - "register base with self-cycling trait ref", + "register base referencing self-cycling trait schema", ), _register_derived( ( @@ -1823,7 +1688,7 @@ def test_start(self): "x.test13._.cyc_self_leaf.v1~" ), False, - "validate should fail - cycling ref in traits-schema", + "validate should fail - true self-cycle in trait-schema chain", ), ] @@ -1929,53 +1794,11 @@ def test_start(self): ), ] -class TestCaseOp13_TraitsInvalid_TraitsSchemaNotObject(HttpRunner): - """OP#13 - Traits: x-gts-traits-schema with type=integer. - - Must fail - trait schema must have type=object. - """ - config = Config( - "OP#13 - Traits Schema Not Object" - ).base_url(get_gts_base_url()) - - def test_start(self): - super().test_start() - - teststeps = [ - _register( - "gts://gts.x.test13.tsnobj.event.v1~", - { - "type": "object", - "x-gts-traits-schema": { - "type": "integer", - }, - "required": ["id"], - "properties": { - "id": {"type": "string"}, - }, - }, - "register base with type=integer trait schema", - ), - _register_derived( - ( - "gts://gts.x.test13.tsnobj.event.v1~" - "x.test13._.tsnobj_leaf.v1~" - ), - "gts://gts.x.test13.tsnobj.event.v1~", - { - "type": "object", - }, - "register derived", - ), - _validate_type_schema( - ( - "gts.x.test13.tsnobj.event.v1~" - "x.test13._.tsnobj_leaf.v1~" - ), - False, - "validate should fail - trait schema type is integer", - ), - ] +# NOTE (v0.12): TestCaseOp13_TraitsInvalid_TraitsSchemaNotObject was removed. +# Under ADR-0002 the value space for x-gts-traits-schema is "subschema OR true +# OR false"; an object subschema with type=integer is admissible *syntactically*. +# Trait-value violations against such a schema are covered by +# TestCaseOp13_TraitsValueViolatesIntegerSchema below. class TestCaseOp13_TraitsInvalid_TraitsInInstance(HttpRunner): @@ -2085,3 +1908,2307 @@ def test_start(self): "validate entity should fail - traits-schema in instance", ), ] + + +# --------------------------------------------------------------------------- +# ADR-0002: x-gts-traits-schema as a JSON Schema subschema +# +# Value MAY be an object subschema, boolean true, or boolean false. The +# effective trait-schema at any type is the allOf of all declarations along +# the $id chain. Descendants need not repeat ancestor declarations, but if +# they declare a trait-schema it must be compatible with the chain. +# --------------------------------------------------------------------------- + + +class TestCaseOp13_TraitsSchema_DescendantOmitsAncestorDecl_AggregatedViaAllOf(HttpRunner): + """ADR-0002: descendant omits x-gts-traits-schema; effective is aggregated. + + Base declares the trait-schema; derived declares no x-gts-traits-schema + of its own but supplies a value. The registry composes ancestor + declarations via allOf; the value satisfies the aggregated schema. + """ + + config = Config("OP#13 ADR-0002: descendant omits trait-schema decl").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.aggomit.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "additionalProperties": False, + "properties": { + "retention": {"type": "string"}, + }, + "required": ["retention"], + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with trait-schema", + ), + _register_derived( + "gts://gts.x.test13.aggomit.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.aggomit.event.v1~", + { + "type": "object", + "x-gts-traits": {"retention": "P90D"}, + }, + "register derived - no trait-schema decl, supplies value only", + ), + _validate_type_schema( + "gts.x.test13.aggomit.event.v1~x.test13._.kid.v1~", + True, + "validate derived - aggregated trait-schema satisfied", + ), + ] + + +class TestCaseOp13_TraitsSchema_DescendantAddsNewField(HttpRunner): + """ADR-0002: descendant adds a new trait field; aggregated via allOf. + + Base declares `retention`. Descendant declares only `supportLevel` + (without restating retention). Effective trait-schema requires both; + descendant supplies both values; passes. + """ + + config = Config("OP#13 ADR-0002: descendant adds new trait field").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.aggadd.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": {"type": "string"}, + }, + "required": ["retention"], + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with retention", + ), + _register_derived( + "gts://gts.x.test13.aggadd.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.aggadd.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "supportLevel": {"type": "string"}, + }, + "required": ["supportLevel"], + }, + "x-gts-traits": { + "retention": "P30D", + "supportLevel": "premium", + }, + }, + "register derived adding supportLevel only", + ), + _validate_type_schema( + "gts.x.test13.aggadd.event.v1~x.test13._.kid.v1~", + True, + "validate derived - aggregated allOf accepts both fields", + ), + ] + + +class TestCaseOp13_TraitsSchema_BooleanTrue_AnyTraitsAllowed(HttpRunner): + """ADR-0002: x-gts-traits-schema: true permits arbitrary traits. + + Subschema `true` accepts any value; descendant may carry any + x-gts-traits object. + """ + + config = Config("OP#13 ADR-0002: trait-schema true permits any traits").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.aggtrue.event.v1~", + { + "type": "object", + "x-gts-traits-schema": True, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with trait-schema: true", + ), + _register_derived( + "gts://gts.x.test13.aggtrue.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.aggtrue.event.v1~", + { + "type": "object", + "x-gts-traits": { + "retention": "P30D", + "topic": "events", + "anything": 42, + }, + }, + "register derived with arbitrary traits", + ), + _validate_type_schema( + "gts.x.test13.aggtrue.event.v1~x.test13._.kid.v1~", + True, + "validate derived - any traits allowed under true", + ), + ] + + +class TestCaseOp13_TraitsSchema_BooleanFalse_NoTraits_Ok(HttpRunner): + """ADR-0002: x-gts-traits-schema: false prohibits traits, not descendants. + + A concrete descendant with no x-gts-traits MUST register and validate. + `false` rules out traits, not the existence of typed descendants. + """ + + config = Config("OP#13 ADR-0002: trait-schema false + no traits passes").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.aggfalse.event.v1~", + { + "type": "object", + "x-gts-traits-schema": False, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with trait-schema: false", + ), + _register_derived( + "gts://gts.x.test13.aggfalse.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.aggfalse.event.v1~", + {"type": "object"}, + "register derived - no x-gts-traits", + ), + _validate_type_schema( + "gts.x.test13.aggfalse.event.v1~x.test13._.kid.v1~", + True, + "validate derived - no traits, false-schema not exercised", + ), + ] + + +class TestCaseOp13_TraitsSchema_BooleanFalse_DescendantSetsTraits_Fails(HttpRunner): + """ADR-0002: descendant tries to set x-gts-traits against a false-schema. + + Aggregated effective schema is `false`; any traits object fails validation. + """ + + config = Config("OP#13 ADR-0002: trait-schema false rejects traits").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.aggfalsetr.event.v1~", + { + "type": "object", + "x-gts-traits-schema": False, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with trait-schema: false", + ), + _register_derived( + "gts://gts.x.test13.aggfalsetr.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.aggfalsetr.event.v1~", + { + "type": "object", + "x-gts-traits": {"retention": "P30D"}, + }, + "register derived with traits against false-schema", + ), + _validate_type_schema( + "gts.x.test13.aggfalsetr.event.v1~x.test13._.kid.v1~", + False, + "validate derived - traits rejected by false-schema", + ), + ] + + +class TestCaseOp13_TraitsSchema_BooleanFalse_Inherits_DescendantSetsTraits_Fails(HttpRunner): + """ADR-0002: false anywhere in the chain makes effective schema unsatisfiable. + + Base is false; mid declares an object trait-schema; leaf supplies traits. + allOf(false, {...}) is false; leaf's traits cannot validate. + """ + + config = Config("OP#13 ADR-0002: false inherited blocks traits").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.aggfalsei.event.v1~", + { + "type": "object", + "x-gts-traits-schema": False, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with trait-schema: false", + ), + _register_derived( + "gts://gts.x.test13.aggfalsei.event.v1~x.test13._.mid.v1~", + "gts://gts.x.test13.aggfalsei.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}}, + }, + }, + "register mid declaring object trait-schema", + ), + _register_derived( + ( + "gts://gts.x.test13.aggfalsei.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + "gts://gts.x.test13.aggfalsei.event.v1~x.test13._.mid.v1~", + { + "type": "object", + "x-gts-traits": {"retention": "P30D"}, + }, + "register leaf supplying traits", + ), + _validate_type_schema( + ( + "gts.x.test13.aggfalsei.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + False, + "validate leaf - allOf(false, {...}) = false", + ), + ] + + +class TestCaseOp13_TraitsSchema_IncompatibleDescendantSchema_Fails(HttpRunner): + """ADR-0002: descendant's own trait-schema must be compatible with ancestor. + + Parent declares retention.minLength=5; descendant declares + retention.maxLength=3. Aggregated allOf is unsatisfiable on `retention` + — a non-abstract descendant with `retention` required cannot satisfy + completeness with any value. + """ + + config = Config("OP#13 ADR-0002: incompatible descendant trait-schema").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.aggincomp.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": {"type": "string", "minLength": 5}, + }, + "required": ["retention"], + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with minLength=5", + ), + _register_derived( + "gts://gts.x.test13.aggincomp.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.aggincomp.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": {"type": "string", "maxLength": 3}, + }, + }, + "x-gts-traits": {"retention": "P30D"}, + }, + "register derived with incompatible maxLength=3", + ), + _validate_type_schema( + "gts.x.test13.aggincomp.event.v1~x.test13._.kid.v1~", + False, + "validate derived - aggregated allOf is unsatisfiable", + ), + ] + + +class TestCaseOp13_TraitsSchema_RedundantAncestorRefAllowed(HttpRunner): + """ADR-0002 §"Patterns within Option 2A": redundant ancestor-ref is allowed. + + Trait-schema lives at a standalone GTS type and is referenced from the + base's x-gts-traits-schema. The descendant explicitly composes its own + trait-schema as allOf:[{$ref: ancestor-trait-schema-type}, {delta}]. + Under chain-aggregation this manual composition is redundant (the chain + already aggregates via allOf), but not invalid per the extension framing + (any syntactically valid JSON Schema is a valid GTS Type Schema; ADR-0001). + """ + + config = Config("OP#13 ADR-0002: redundant ancestor ref allowed").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.aggrr.trschema.v1~", + { + "type": "object", + "properties": {"retention": {"type": "string"}}, + "required": ["retention"], + }, + "register standalone ancestor trait-schema type", + ), + _register( + "gts://gts.x.test13.aggrr.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "$$ref": "gts://gts.x.test13.aggrr.trschema.v1~", + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base referencing the standalone trait-schema", + ), + _register_derived( + "gts://gts.x.test13.aggrr.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.aggrr.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "allOf": [ + {"$$ref": "gts://gts.x.test13.aggrr.trschema.v1~"}, + {"type": "object", "properties": {"supportLevel": {"type": "string"}}}, + ], + }, + "x-gts-traits": {"retention": "P30D", "supportLevel": "premium"}, + }, + "register derived with explicit redundant ancestor-ref in trait-schema", + ), + _validate_type_schema( + "gts.x.test13.aggrr.event.v1~x.test13._.kid.v1~", + True, + "validate derived - redundant ref is allowed", + ), + ] + + +class TestCaseOp13_TraitsSchema_ObjectFormStillValid(HttpRunner): + """ADR-0002 regression: pre-v0.12 object form remains valid. + + Ensures the dominant historical form (x-gts-traits-schema as an object) + is unchanged by the v0.12 subschema framing that admits booleans + (ADR-0002). + """ + + config = Config("OP#13 ADR-0002: object form still valid").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.aggobjform.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "additionalProperties": False, + "properties": { + "retention": {"type": "string", "default": "P30D"}, + }, + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with classic object trait-schema", + ), + _validate_type_schema( + "gts.x.test13.aggobjform.event.v1~", + True, + "validate base - object form still valid", + ), + ] + + +class TestCaseOp13_TraitsSchema_StandaloneRefPattern_3Level(HttpRunner): + """ADR-0002: standalone $ref'd trait-schema + 3-level chain aggregation. + + Trait-schema lives at a standalone $id and is $ref'd by the host's + x-gts-traits-schema. Mid level adds another trait-schema declaration + that aggregates via allOf. Leaf supplies values for both layers. + """ + + config = Config("OP#13 ADR-0002: standalone ref-pattern + 3-level chain").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.aggrp.tschema.v1~", + { + "type": "object", + "properties": {"retention": {"type": "string"}}, + "required": ["retention"], + }, + "register standalone trait-schema type", + ), + _register( + "gts://gts.x.test13.aggrp.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "$$ref": "gts://gts.x.test13.aggrp.tschema.v1~", + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with $ref'd trait-schema", + ), + _register_derived( + "gts://gts.x.test13.aggrp.event.v1~x.test13._.mid.v1~", + "gts://gts.x.test13.aggrp.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"supportLevel": {"type": "string"}}, + "required": ["supportLevel"], + }, + }, + "register mid adding supportLevel inline", + ), + _register_derived( + ( + "gts://gts.x.test13.aggrp.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + "gts://gts.x.test13.aggrp.event.v1~x.test13._.mid.v1~", + { + "type": "object", + "x-gts-traits": {"retention": "P30D", "supportLevel": "gold"}, + }, + "register leaf supplying both traits", + ), + _validate_type_schema( + ( + "gts.x.test13.aggrp.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + True, + "validate leaf - 3-level aggregation across ref-pattern + inline", + ), + ] + + +# --------------------------------------------------------------------------- +# ADR-0003: trait completeness keyed on x-gts-abstract +# +# Completeness is enforced (via /validate-type-schema) for non-abstract types +# only. The materialized effective traits object (defaults substituted) MUST +# satisfy the effective trait-schema. Abstract types skip the check. +# --------------------------------------------------------------------------- + + +class TestCaseOp13_Completeness_AbstractType_UnresolvedRequired_Succeeds(HttpRunner): + """ADR-0003: abstract types skip completeness even with unresolved required. + + Base declares a required trait with no default and provides no value; + base is x-gts-abstract: true; validation passes (check skipped). + """ + + config = Config("OP#13 ADR-0003: abstract skips completeness").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register_abstract( + "gts://gts.x.test13.compabs.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + "required": ["topicRef"], + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register abstract base with unresolved required trait", + ), + _validate_type_schema( + "gts.x.test13.compabs.event.v1~", + True, + "validate abstract - completeness skipped", + ), + ] + + +class TestCaseOp13_Completeness_NonAbstractIntermediate_UnresolvedRequired_Fails(HttpRunner): + """ADR-0003: completeness applies to every non-abstract type, not just leaves. + + Three-level chain A→B→C. B is non-abstract and has descendants registered + after it. B leaves the required trait unresolved. The old leaf-based rule + would have allowed B; the new rule fails it. Validate B and observe FAIL. + """ + + config = Config("OP#13 ADR-0003: non-abstract intermediate must be complete").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register_abstract( + "gts://gts.x.test13.compmid.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + "required": ["topicRef"], + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register abstract A (root)", + ), + _register_derived( + "gts://gts.x.test13.compmid.event.v1~x.test13._.mid_b.v1~", + "gts://gts.x.test13.compmid.event.v1~", + { + "type": "object", + }, + "register non-abstract B without resolving topicRef", + ), + _register_derived( + ( + "gts://gts.x.test13.compmid.event.v1~" + "x.test13._.mid_b.v1~x.test13._.leaf_c.v1~" + ), + "gts://gts.x.test13.compmid.event.v1~x.test13._.mid_b.v1~", + { + "type": "object", + "x-gts-traits": {"topicRef": "events.orders"}, + }, + "register leaf C resolving the trait", + ), + _validate_type_schema( + "gts.x.test13.compmid.event.v1~x.test13._.mid_b.v1~", + False, + "validate B - non-abstract with unresolved required trait, must fail", + ), + ] + + +class TestCaseOp13_Completeness_AbstractBase_ConcreteDescendantSatisfies(HttpRunner): + """ADR-0003: abstract base + concrete descendant filling gaps. + + Abstract A with required trait, no default. Non-abstract B supplies value. + A passes (skip). B passes (resolved). + """ + + config = Config("OP#13 ADR-0003: abstract base + concrete satisfies").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register_abstract( + "gts://gts.x.test13.compabsb.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + "required": ["topicRef"], + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register abstract base", + ), + _register_derived( + "gts://gts.x.test13.compabsb.event.v1~x.test13._.concrete.v1~", + "gts://gts.x.test13.compabsb.event.v1~", + { + "type": "object", + "x-gts-traits": {"topicRef": "events.orders"}, + }, + "register concrete descendant resolving trait", + ), + _validate_type_schema( + "gts.x.test13.compabsb.event.v1~", + True, + "validate abstract - skipped", + ), + _validate_type_schema( + "gts.x.test13.compabsb.event.v1~x.test13._.concrete.v1~", + True, + "validate concrete - trait resolved", + ), + ] + + +class TestCaseOp13_Completeness_DefaultSatisfiesRequired(HttpRunner): + """ADR-0003: materialization with default satisfies completeness. + + Required trait has a default. Non-abstract type omits x-gts-traits; the + default fills the gap during materialization, validation passes. + """ + + config = Config("OP#13 ADR-0003: default satisfies required").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.compdfl.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": {"type": "string", "default": "P7D"}, + }, + "required": ["retention"], + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with default on required trait", + ), + _validate_type_schema( + "gts.x.test13.compdfl.event.v1~", + True, + "validate non-abstract base - default materialized", + ), + ] + + +class TestCaseOp13_Completeness_NullDelete_RequiredNoDefault_Fails(HttpRunner): + """ADR-0003/0004: null in descendant deletes ancestor's value. + + Ancestor sets the required trait; descendant writes null; no default + available. After RFC 7396 merge the key is removed and materialization + cannot re-apply a default. Non-abstract descendant fails completeness. + """ + + config = Config("OP#13 ADR-0003: null-delete required no default fails").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.compnreq.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + "required": ["topicRef"], + }, + "x-gts-traits": {"topicRef": "events.orders"}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with topicRef set, no default", + ), + _register_derived( + "gts://gts.x.test13.compnreq.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.compnreq.event.v1~", + { + "type": "object", + "x-gts-traits": {"topicRef": None}, + }, + "register concrete descendant nulling topicRef", + ), + _validate_type_schema( + "gts.x.test13.compnreq.event.v1~x.test13._.kid.v1~", + False, + "validate descendant - required trait deleted with no default", + ), + ] + + +# --------------------------------------------------------------------------- +# ADR-0004: x-gts-traits merge strategy (RFC 7396 JSON Merge Patch) +# +# Traits merge along the $id chain root → leaf. Scalars last-win; objects +# merge recursively; arrays replace wholesale; null deletes the key, after +# which ADR-0003 materialization re-applies any default. Locking is done via +# standard JSON Schema `const` in x-gts-traits-schema; the registry does not +# carry a GTS-specific immutability rule. +# --------------------------------------------------------------------------- + + +class TestCaseOp13_Merge_ScalarOverride_LastWins(HttpRunner): + """ADR-0004: descendant overrides ancestor scalar trait — last wins. + + Replaces the deleted TraitsInvalid_OverrideInChain. Under v0.12 the + descendant value is the effective value; registration succeeds. + """ + + config = Config("OP#13 ADR-0004: scalar override last-wins").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mscalar.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}}, + "required": ["retention"], + }, + "x-gts-traits": {"retention": "P30D"}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with retention=P30D", + ), + _register_derived( + "gts://gts.x.test13.mscalar.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mscalar.event.v1~", + { + "type": "object", + "x-gts-traits": {"retention": "P365D"}, + }, + "register descendant overriding to P365D", + ), + _validate_type_schema( + "gts.x.test13.mscalar.event.v1~x.test13._.kid.v1~", + True, + "validate descendant - scalar last-wins", + ), + ] + + +class TestCaseOp13_Merge_3Layer_MiddleOverridesBase_LeafOverridesMiddle(HttpRunner): + """ADR-0004 §"Conformance test suite" (g): 3-layer chain, each overrides ancestor.""" + + config = Config("OP#13 ADR-0004: 3-layer last-wins").base_url(get_gts_base_url()) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.m3layer.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}}, + "required": ["retention"], + }, + "x-gts-traits": {"retention": "P7D"}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base retention=P7D", + ), + _register_derived( + "gts://gts.x.test13.m3layer.event.v1~x.test13._.mid.v1~", + "gts://gts.x.test13.m3layer.event.v1~", + { + "type": "object", + "x-gts-traits": {"retention": "P30D"}, + }, + "register mid retention=P30D", + ), + _register_derived( + ( + "gts://gts.x.test13.m3layer.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + "gts://gts.x.test13.m3layer.event.v1~x.test13._.mid.v1~", + { + "type": "object", + "x-gts-traits": {"retention": "P365D"}, + }, + "register leaf retention=P365D", + ), + _validate_type_schema( + ( + "gts.x.test13.m3layer.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + True, + "validate leaf - 3-layer last-wins", + ), + ] + + +class TestCaseOp13_Merge_NestedObject_RecursiveMerge(HttpRunner): + """ADR-0004 §"Conformance test suite" (b): nested-object recursive merge. + + Base sets routing.{topic, partitionKey}; descendant overrides only `topic`. + Effective routing retains `partitionKey` from base; the trait-schema + requires both fields and is satisfied without the descendant restating + partitionKey. + """ + + config = Config("OP#13 ADR-0004: nested object recursive merge").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mnested.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "routing": { + "type": "object", + "properties": { + "topic": {"type": "string"}, + "partitionKey": {"type": "string"}, + }, + "required": ["topic", "partitionKey"], + }, + }, + "required": ["routing"], + }, + "x-gts-traits": { + "routing": {"topic": "events", "partitionKey": "userId"}, + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with routing.{topic, partitionKey}", + ), + _register_derived( + "gts://gts.x.test13.mnested.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mnested.event.v1~", + { + "type": "object", + "x-gts-traits": { + "routing": {"topic": "orders"}, + }, + }, + "register descendant overriding only routing.topic", + ), + _validate_type_schema( + "gts.x.test13.mnested.event.v1~x.test13._.kid.v1~", + True, + "validate descendant - partitionKey preserved by recursive merge", + ), + ] + + +class TestCaseOp13_Merge_ArrayReplacesWholesale(HttpRunner): + """ADR-0004 §"Conformance test suite" (c): arrays replace wholesale. + + Ancestor sets tags=["a","b","base"]; descendant replaces with ["new"]. + Effective is ["new"] — no element-level merging. We assert this both + positively (replacement accepted under a permissive items schema) and + negatively (next class) via a constraint the new array violates. + """ + + config = Config("OP#13 ADR-0004: array replaces wholesale").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.marrayrp.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + }, + }, + "required": ["tags"], + }, + "x-gts-traits": {"tags": ["a", "b", "base"]}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base tags=[a,b,base]", + ), + _register_derived( + "gts://gts.x.test13.marrayrp.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.marrayrp.event.v1~", + { + "type": "object", + "x-gts-traits": {"tags": ["new"]}, + }, + "register descendant tags=[new]", + ), + _validate_type_schema( + "gts.x.test13.marrayrp.event.v1~x.test13._.kid.v1~", + True, + "validate descendant - replacement accepted (minItems=1)", + ), + ] + + +class TestCaseOp13_Merge_ArrayReplacesWholesale_NegativeProvesNoMerge(HttpRunner): + """ADR-0004 companion: ancestor had 3 items, descendant array of 1 must + fail a minItems=2 constraint — proving array merge is replacement, not union. + """ + + config = Config("OP#13 ADR-0004: array replace, no merge").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.marrnomerg.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": {"type": "string"}, + "minItems": 2, + }, + }, + "required": ["tags"], + }, + "x-gts-traits": {"tags": ["a", "b", "base"]}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base tags has 3 items, schema requires minItems=2", + ), + _register_derived( + "gts://gts.x.test13.marrnomerg.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.marrnomerg.event.v1~", + { + "type": "object", + "x-gts-traits": {"tags": ["only-one"]}, + }, + "register descendant tags=[only-one] (1 item)", + ), + _validate_type_schema( + "gts.x.test13.marrnomerg.event.v1~x.test13._.kid.v1~", + False, + "validate descendant - replacement violates minItems=2", + ), + ] + + +class TestCaseOp13_Merge_NullDelete_FallsBackToDefault(HttpRunner): + """ADR-0004 §"Conformance test suite" (d): null deletes; default re-applies. + + Base sets retention=P30D; schema also declares default=P7D. Descendant + writes null. Merged object omits retention; ADR-0003 materialization + substitutes default=P7D; validation passes. + """ + + config = Config("OP#13 ADR-0004: null-delete falls back to default").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mnulldfl.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": {"type": "string", "default": "P7D"}, + }, + "required": ["retention"], + }, + "x-gts-traits": {"retention": "P30D"}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base retention=P30D, default=P7D", + ), + _register_derived( + "gts://gts.x.test13.mnulldfl.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mnulldfl.event.v1~", + { + "type": "object", + "x-gts-traits": {"retention": None}, + }, + "register descendant nulling retention", + ), + _validate_type_schema( + "gts.x.test13.mnulldfl.event.v1~x.test13._.kid.v1~", + True, + "validate descendant - default re-applied after null delete", + ), + ] + + +class TestCaseOp13_Merge_NullDelete_NoDefault_AbstractOk(HttpRunner): + """ADR-0004 + ADR-0003: null-delete on a required-no-default trait, + descendant is abstract → completeness skipped → passes. + """ + + config = Config("OP#13 ADR-0004: null-delete + abstract descendant ok").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mnullabs.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + "required": ["topicRef"], + }, + "x-gts-traits": {"topicRef": "events.orders"}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with topicRef set, no default", + ), + _register_abstract( + "gts://gts.x.test13.mnullabs.event.v1~x.test13._.kid.v1~", + { + "type": "object", + "allOf": [ + {"$$ref": "gts://gts.x.test13.mnullabs.event.v1~"}, + ], + "x-gts-traits": {"topicRef": None}, + }, + "register abstract descendant nulling topicRef", + ), + _validate_type_schema( + "gts.x.test13.mnullabs.event.v1~x.test13._.kid.v1~", + True, + "validate abstract descendant - completeness skipped", + ), + ] + + +class TestCaseOp13_Merge_ConstLock_DescendantOverrideFails(HttpRunner): + """ADR-0004 §"Conformance test suite" (f): publisher locks with const. + + Trait-schema declares `indexed: { const: true }`. Base sets indexed=true. + Descendant tries indexed=false. Merged value `false` fails JSON Schema + validation against the aggregated trait-schema's const — standard + mechanism, no GTS-specific rule. + """ + + config = Config("OP#13 ADR-0004: const lock rejects override").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mconstlk.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "indexed": {"type": "boolean", "const": True}, + }, + "required": ["indexed"], + }, + "x-gts-traits": {"indexed": True}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with const-locked indexed=true", + ), + _register_derived( + "gts://gts.x.test13.mconstlk.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mconstlk.event.v1~", + { + "type": "object", + "x-gts-traits": {"indexed": False}, + }, + "register descendant trying indexed=false", + ), + _validate_type_schema( + "gts.x.test13.mconstlk.event.v1~x.test13._.kid.v1~", + False, + "validate descendant - const violated by override", + ), + ] + + +class TestCaseOp13_Merge_ConstLock_IdempotentRestatementOk(HttpRunner): + """ADR-0004: descendant restates const-locked value. Passes.""" + + config = Config("OP#13 ADR-0004: const lock idempotent restatement").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mconstid.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "indexed": {"type": "boolean", "const": True}, + }, + "required": ["indexed"], + }, + "x-gts-traits": {"indexed": True}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with const-locked indexed=true", + ), + _register_derived( + "gts://gts.x.test13.mconstid.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mconstid.event.v1~", + { + "type": "object", + "x-gts-traits": {"indexed": True}, + }, + "register descendant restating indexed=true", + ), + _validate_type_schema( + "gts.x.test13.mconstid.event.v1~x.test13._.kid.v1~", + True, + "validate descendant - idempotent restatement allowed", + ), + ] + + +class TestCaseOp13_Merge_IdempotentScalarRestatement(HttpRunner): + """ADR-0004 §"Conformance test suite" (e): idempotent restatement of a scalar. + + Descendant repeats the ancestor's value verbatim — merge yields the same + value; passes. Documents that the deleted "MUST fail on identical + restatement" interpretation is gone. + """ + + config = Config("OP#13 ADR-0004: idempotent scalar restatement").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.midemp.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}}, + "required": ["retention"], + }, + "x-gts-traits": {"retention": "P30D"}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base retention=P30D", + ), + _register_derived( + "gts://gts.x.test13.midemp.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.midemp.event.v1~", + { + "type": "object", + "x-gts-traits": {"retention": "P30D"}, + }, + "register descendant restating retention=P30D", + ), + _validate_type_schema( + "gts.x.test13.midemp.event.v1~x.test13._.kid.v1~", + True, + "validate descendant - idempotent restatement passes", + ), + ] + + +class TestCaseOp13_TraitsValueViolatesIntegerSchema(HttpRunner): + """ADR-0002 successor to the deleted TraitsSchemaNotObject case. + + x-gts-traits-schema is an object subschema with `type:object`, declaring + a single `count` property of type:integer. The deleted case rejected the + base because the old §9.7 gate required `x-gts-traits-schema` to declare + `type: object` at the top level and treated *any* non-object form as a + registration-time failure. ADR-0002 lifts that gate — the value space is + now subschema OR `true` OR `false`. Note the spec still requires the + *effective* (chain-aggregated) trait-schema to constrain trait values to + JSON objects when expressed in object subschema form (README §9.7), so a + bare top-level `{type: integer}` remains practically unsatisfiable — but + a property-level integer constraint (as here) is fine. What this test + exercises is the value-side enforcement that remains: a descendant + supplying a non-integer for `count` MUST fail JSON Schema validation + against the effective trait-schema. + """ + + config = Config("OP#13 ADR-0002: trait value violates integer schema").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mintsch.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"count": {"type": "integer"}}, + "required": ["count"], + }, + "x-gts-traits": {"count": 7}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with integer-typed count and value 7", + ), + _register_derived( + "gts://gts.x.test13.mintsch.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mintsch.event.v1~", + { + "type": "object", + "x-gts-traits": {"count": "not-an-int"}, + }, + "register descendant with non-integer count", + ), + _validate_type_schema( + "gts.x.test13.mintsch.event.v1~x.test13._.kid.v1~", + False, + "validate descendant - value fails integer schema", + ), + ] + + +class TestCaseOp13_Merge_NestedNullDelete_PreservesPeer(HttpRunner): + """ADR-0004 Worked example B (null-at-nested-depth variant). + + Base sets `routing: {topic, partitionKey}`; descendant writes + `routing: {partitionKey: null}`. RFC 7396 descends into `routing` and + deletes `partitionKey` at the leaf, while preserving the peer key + `topic`. The trait-schema requires only `topic` inside `routing`, so + completeness passes. + + This case visibly distinguishes RFC 7396 (Option 2c) from shallow + last-wins (Option 2a): under shallow merge the descendant's `routing` + object would replace the ancestor's wholesale, giving an effective + `routing` with no `topic` at all — completeness would fail. Recursive + merge descends into `routing`, applies `partitionKey: null` as a leaf + delete, and keeps the ancestor's `topic`. + """ + + config = Config("OP#13 ADR-0004: nested null-delete preserves peer").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mnestnull.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "routing": { + "type": "object", + "properties": { + "topic": {"type": "string"}, + "partitionKey": {"type": "string"}, + }, + "required": ["topic"], + }, + }, + "required": ["routing"], + }, + "x-gts-traits": { + "routing": {"topic": "events", "partitionKey": "userId"}, + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with routing.{topic, partitionKey}", + ), + _register_derived( + "gts://gts.x.test13.mnestnull.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mnestnull.event.v1~", + { + "type": "object", + "x-gts-traits": { + "routing": {"partitionKey": None}, + }, + }, + "register descendant deleting only routing.partitionKey", + ), + _validate_type_schema( + "gts.x.test13.mnestnull.event.v1~x.test13._.kid.v1~", + True, + "validate descendant - routing.topic preserved by recursive merge", + ), + ] + + +class TestCaseOp13_Merge_ConstLock_PreservedAcrossDescendant(HttpRunner): + """ADR-0004 Worked example C, Descendant A: const-locked value flows down. + + Trait-schema declares `indexed: {const: true}` and `retention` open. + Base sets `indexed: true` and `retention: P30D`. Descendant overrides + ONLY `retention`; it does NOT restate `indexed`. RFC 7396 merge + preserves `indexed: true` from the chain; the materialized effective + traits object satisfies `const: true` for `indexed` and the descendant's + new retention value. Validation passes — the publisher's lock holds + naturally, without requiring the descendant to know about it. + """ + + config = Config("OP#13 ADR-0004: const-locked value preserved across descendant").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mconstpres.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "indexed": {"type": "boolean", "const": True}, + "retention": {"type": "string"}, + }, + "required": ["indexed", "retention"], + }, + "x-gts-traits": {"indexed": True, "retention": "P30D"}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with const-locked indexed and open retention", + ), + _register_derived( + "gts://gts.x.test13.mconstpres.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mconstpres.event.v1~", + { + "type": "object", + "x-gts-traits": {"retention": "P365D"}, + }, + "register descendant overriding only retention", + ), + _validate_type_schema( + "gts.x.test13.mconstpres.event.v1~x.test13._.kid.v1~", + True, + "validate descendant - const-locked indexed preserved from chain", + ), + ] + + +# --------------------------------------------------------------------------- +# ADR-0002: additional x-gts-traits-schema value-space / aggregation coverage +# --------------------------------------------------------------------------- + + +class TestCaseOp13_TraitsSchema_NonObjectEffective_RejectsTraits(HttpRunner): + """ADR-0002 / README §9.7: a non-object effective trait-schema rejects traits. + + The value space now admits any subschema, but §9.7 still requires the + *effective* object-form trait-schema to constrain trait values to JSON + objects. A bare `{type: integer}` effective trait-schema can therefore + never be satisfied by an `x-gts-traits` object — the traits value is a JSON + object, which is not an integer. This is the behavioral backstop that + replaced the deleted syntactic `TraitsSchemaNotObject` gate (which the + successor integer-property case did not directly cover). Validation fails. + """ + + config = Config("OP#13 ADR-0002: non-object effective trait-schema rejects traits").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.nonobjts.event.v1~", + { + "type": "object", + "x-gts-traits-schema": {"type": "integer"}, + "x-gts-traits": {"retention": "P30D"}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base whose effective trait-schema is {type:integer}", + ), + _validate_type_schema( + "gts.x.test13.nonobjts.event.v1~", + False, + "validate - traits object cannot satisfy a non-object effective schema", + ), + ] + + +class TestCaseOp13_TraitsSchema_BooleanFalse_AtLeaf_OptOut_Fails(HttpRunner): + """ADR-0002: a leaf may opt out by declaring `x-gts-traits-schema: false`. + + ADR-0002 calls out the leaf opt-out explicitly. The base declares a + permissive object trait-schema; the descendant declares `false` AND still + carries `x-gts-traits`. The chain-aggregated effective schema is + `allOf(object-schema, false)` = false, so any traits are rejected. + Counterpart to BooleanFalse_DescendantSetsTraits, with `false` at the leaf. + """ + + config = Config("OP#13 ADR-0002: false at leaf opt-out rejects traits").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.falseleaf.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}}, + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with permissive object trait-schema", + ), + _register_derived( + "gts://gts.x.test13.falseleaf.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.falseleaf.event.v1~", + { + "type": "object", + "x-gts-traits-schema": False, + "x-gts-traits": {"retention": "P7D"}, + }, + "register leaf declaring traits-schema false but still carrying traits", + ), + _validate_type_schema( + "gts.x.test13.falseleaf.event.v1~x.test13._.kid.v1~", + False, + "validate leaf - false makes effective schema unsatisfiable", + ), + ] + + +class TestCaseOp13_TraitsSchema_BooleanTrue_Identity_DescendantConstrains(HttpRunner): + """ADR-0002: `true` is the identity element under allOf aggregation. + + Base declares `x-gts-traits-schema: true` (admits anything). The descendant + introduces an object trait-schema with a required property and supplies its + value. The effective schema is `allOf(true, {object})` = the object schema; + `true` contributes nothing. Validation passes. Symmetric positive companion + to the well-covered `false` aggregation cases. + """ + + config = Config("OP#13 ADR-0002: true is identity in aggregation").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.trueid.event.v1~", + { + "type": "object", + "x-gts-traits-schema": True, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with traits-schema true", + ), + _register_derived( + "gts://gts.x.test13.trueid.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.trueid.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + "required": ["topicRef"], + }, + "x-gts-traits": {"topicRef": "events.orders"}, + }, + "register descendant adding object trait-schema + value", + ), + _validate_type_schema( + "gts.x.test13.trueid.event.v1~x.test13._.kid.v1~", + True, + "validate descendant - true contributed nothing, object schema satisfied", + ), + ] + + +# --------------------------------------------------------------------------- +# ADR-0003: additional completeness × abstract boundary coverage +# --------------------------------------------------------------------------- + + +class TestCaseOp13_Completeness_AbstractDroppedByConcreteDescendant_Fails(HttpRunner): + """ADR-0003 + §9.11.3(4): abstractness does not propagate. + + Abstract A declares a required trait with no default (completeness skipped). + Concrete B derives from A, does NOT declare x-gts-abstract, and does NOT + resolve the required trait. Because a derived type is concrete by default, + B MUST satisfy completeness — and fails. This is the symmetric failure + counterpart to AbstractBase_ConcreteDescendantSatisfies. + """ + + config = Config("OP#13 ADR-0003: concrete descendant of abstract must complete").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register_abstract( + "gts://gts.x.test13.absdrop.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + "required": ["topicRef"], + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register abstract base with unresolved required trait", + ), + _register_derived( + "gts://gts.x.test13.absdrop.event.v1~x.test13._.concrete.v1~", + "gts://gts.x.test13.absdrop.event.v1~", + { + "type": "object", + }, + "register concrete descendant that does NOT resolve the trait", + ), + _validate_type_schema( + "gts.x.test13.absdrop.event.v1~x.test13._.concrete.v1~", + False, + "validate concrete descendant - inherited required trait unresolved", + ), + ] + + +class TestCaseOp13_Completeness_AbstractIntermediate_NonAbstractLeafMustComplete(HttpRunner): + """ADR-0003: an abstract intermediate is skipped, but a non-abstract leaf is not. + + Chain R → M → L. R is concrete with no traits (trivially complete). M is + abstract and introduces a required trait with no default — its completeness + is skipped. L is non-abstract and does NOT resolve the trait, so L fails. + Validating M passes (skipped); validating L fails. Confirms the rule keys on + each type's own x-gts-abstract, independent of chain position. + """ + + config = Config("OP#13 ADR-0003: abstract intermediate, leaf must complete").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.absint.event.v1~", + { + "type": "object", + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register concrete root R with no traits", + ), + _register_derived( + "gts://gts.x.test13.absint.event.v1~x.test13._.mid.v1~", + "gts://gts.x.test13.absint.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + "required": ["topicRef"], + }, + }, + "register abstract mid M introducing an unresolved required trait", + top_level={"x-gts-abstract": True}, + ), + _register_derived( + ( + "gts://gts.x.test13.absint.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + "gts://gts.x.test13.absint.event.v1~x.test13._.mid.v1~", + { + "type": "object", + }, + "register non-abstract leaf L that does NOT resolve the trait", + ), + _validate_type_schema( + "gts.x.test13.absint.event.v1~x.test13._.mid.v1~", + True, + "validate abstract intermediate M - completeness skipped", + ), + _validate_type_schema( + ( + "gts.x.test13.absint.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + False, + "validate non-abstract leaf L - inherited required trait unresolved", + ), + ] + + +class TestCaseOp13_Completeness_DefaultIntroducedByIntermediate_SatisfiesLeaf(HttpRunner): + """ADR-0003: a default introduced by an intermediate can close a leaf's required. + + Abstract base A declares a required trait with no default (skipped). A + concrete intermediate M redeclares the trait-schema adding a `default` (a + free annotation per §9.7.5). A non-abstract leaf L supplies no value; during + materialization the intermediate's default fills the required trait, so L is + complete. Validation of L passes. + """ + + config = Config("OP#13 ADR-0003: default from intermediate satisfies leaf").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register_abstract( + "gts://gts.x.test13.dflmid.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}}, + "required": ["retention"], + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register abstract base A - required retention, no default", + ), + _register_derived( + "gts://gts.x.test13.dflmid.event.v1~x.test13._.mid.v1~", + "gts://gts.x.test13.dflmid.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string", "default": "P7D"}}, + }, + }, + "register concrete mid M redeclaring trait-schema with a default", + ), + _register_derived( + ( + "gts://gts.x.test13.dflmid.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + "gts://gts.x.test13.dflmid.event.v1~x.test13._.mid.v1~", + { + "type": "object", + }, + "register non-abstract leaf L with no value - relies on the default", + ), + _validate_type_schema( + ( + "gts.x.test13.dflmid.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + True, + "validate leaf - intermediate's default materializes the required trait", + ), + ] + + +# --------------------------------------------------------------------------- +# ADR-0004: additional RFC 7396 merge coverage (recurse-vs-replace boundary, +# null-on-container, effective-value discrimination, depth-3 enforcement) +# --------------------------------------------------------------------------- + + +class TestCaseOp13_Merge_TypeChange_ObjectReplacedByScalar(HttpRunner): + """ADR-0004 / RFC 7396: a non-object patch value replaces wholesale (no recursion). + + Abstract base sets an object-valued `routing`; the trait-schema constrains + `routing` to a string. Because the base is abstract its value is not checked. + The non-abstract descendant sets `routing` to a scalar string. Per RFC 7396 + a non-object member value replaces the target entirely (it does NOT merge + into the ancestor object), so the effective `routing` is the string and + validation passes. Were the registry to recurse, `routing` would remain an + object and fail `type: string` — so the True result discriminates replace + from merge across a type change. + """ + + config = Config("OP#13 ADR-0004: object trait replaced by scalar (no recursion)").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register_abstract( + "gts://gts.x.test13.mtchg.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"routing": {"type": "string"}}, + "required": ["routing"], + }, + "x-gts-traits": {"routing": {"topic": "t", "partitionKey": "k"}}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register abstract base - routing is an object (unchecked, abstract)", + ), + _register_derived( + "gts://gts.x.test13.mtchg.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mtchg.event.v1~", + { + "type": "object", + "x-gts-traits": {"routing": "flat-string"}, + }, + "register descendant replacing routing with a scalar string", + ), + _validate_type_schema( + "gts.x.test13.mtchg.event.v1~x.test13._.kid.v1~", + True, + "validate descendant - scalar replaced the object wholesale", + ), + ] + + +class TestCaseOp13_Merge_NullDelete_WholeObjectKey_Fails(HttpRunner): + """ADR-0004 / RFC 7396: `null` deletes a whole object-valued key. + + Ancestor sets an object-valued required `routing`; the trait-schema requires + it and provides no default. A non-abstract descendant writes `routing: null`, + which deletes the entire key (not just nested fields). Completeness then + fails because a required trait is unresolved with no default. Counterpart to + the scalar null-delete case at object granularity. + """ + + config = Config("OP#13 ADR-0004: null deletes whole object key").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mnullobj.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "routing": { + "type": "object", + "properties": {"topic": {"type": "string"}}, + }, + }, + "required": ["routing"], + }, + "x-gts-traits": {"routing": {"topic": "t"}}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with required object-valued routing, no default", + ), + _register_derived( + "gts://gts.x.test13.mnullobj.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mnullobj.event.v1~", + { + "type": "object", + "x-gts-traits": {"routing": None}, + }, + "register descendant nulling the whole routing object", + ), + _validate_type_schema( + "gts.x.test13.mnullobj.event.v1~x.test13._.kid.v1~", + False, + "validate descendant - whole required object key deleted, no default", + ), + ] + + +class TestCaseOp13_Merge_NullDelete_FallsBackToDefault_ValueDiscriminated(HttpRunner): + """ADR-0004 + ADR-0003: null-delete reverts to default — value-discriminated. + + Strengthens Merge_NullDelete_FallsBackToDefault, which could pass even if an + implementation treated `null` as a no-op (the ancestor value also satisfied + the schema). Here the trait-schema locks `retention` with `const: P7D` and a + matching `default: P7D`. The abstract base sets `retention: P30D` — allowed + only because abstract types skip the check. The non-abstract descendant + writes `retention: null`. If `null` truly deletes, materialization re-applies + the default `P7D`, which satisfies `const` → passes. If the registry ignored + `null`, the inherited `P30D` would violate `const: P7D` → fail. The True + result therefore proves the key was deleted and the default re-applied. + """ + + config = Config("OP#13 ADR-0004: null-delete reverts to default (discriminated)").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register_abstract( + "gts://gts.x.test13.mnulldef.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "retention": {"const": "P7D", "default": "P7D"}, + }, + "required": ["retention"], + }, + "x-gts-traits": {"retention": "P30D"}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register abstract base - retention P30D (skips const check)", + ), + _register_derived( + "gts://gts.x.test13.mnulldef.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mnulldef.event.v1~", + { + "type": "object", + "x-gts-traits": {"retention": None}, + }, + "register descendant nulling retention", + ), + _validate_type_schema( + "gts.x.test13.mnulldef.event.v1~x.test13._.kid.v1~", + True, + "validate descendant - null deleted P30D, default P7D re-applied to satisfy const", + ), + ] + + +class TestCaseOp13_Merge_NullDelete_OptionalKey_NonAbstract_Ok(HttpRunner): + """ADR-0004: null-deleting an optional key on a non-abstract type succeeds. + + The benign positive companion to the required-key delete cases. The trait is + optional (not in `required`); a non-abstract descendant null-deletes it. The + key simply becomes absent and completeness still holds. Validation passes. + """ + + config = Config("OP#13 ADR-0004: null-delete optional key ok").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mnullopt.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"note": {"type": "string"}}, + }, + "x-gts-traits": {"note": "hello"}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with an optional note trait set", + ), + _register_derived( + "gts://gts.x.test13.mnullopt.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mnullopt.event.v1~", + { + "type": "object", + "x-gts-traits": {"note": None}, + }, + "register descendant nulling the optional note", + ), + _validate_type_schema( + "gts.x.test13.mnullopt.event.v1~x.test13._.kid.v1~", + True, + "validate descendant - optional key deleted, still complete", + ), + ] + + +class TestCaseOp13_Merge_NestedArrayReplacedWholesale_PeerPreserved(HttpRunner): + """ADR-0004 / RFC 7396: arrays replace wholesale at any depth, peers still merge. + + `routing` is an object trait with a peer string `topic` and an array + `partitions` (`minItems: 2`). The base sets both; the descendant restates + only `routing.partitions` with a single-element array. Recursive object merge + preserves the peer `topic`, while the nested array is replaced wholesale + (not unioned), leaving 1 element < minItems 2. Validation fails — proving + nested-array replacement combined with peer-object preservation. The base + itself (3 elements) is complete and validates. + """ + + config = Config("OP#13 ADR-0004: nested array replaced wholesale").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mnestarr.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "routing": { + "type": "object", + "properties": { + "topic": {"type": "string"}, + "partitions": {"type": "array", "minItems": 2}, + }, + "required": ["topic", "partitions"], + }, + }, + "required": ["routing"], + }, + "x-gts-traits": { + "routing": {"topic": "t", "partitions": ["a", "b", "c"]}, + }, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base - routing with topic + 3-element partitions", + ), + _register_derived( + "gts://gts.x.test13.mnestarr.event.v1~x.test13._.kid.v1~", + "gts://gts.x.test13.mnestarr.event.v1~", + { + "type": "object", + "x-gts-traits": {"routing": {"partitions": ["only-one"]}}, + }, + "register descendant restating only routing.partitions (1 element)", + ), + _validate_type_schema( + "gts.x.test13.mnestarr.event.v1~", + True, + "validate base - 3-element partitions satisfies minItems", + ), + _validate_type_schema( + "gts.x.test13.mnestarr.event.v1~x.test13._.kid.v1~", + False, + "validate descendant - nested array replaced (1<2), peer topic preserved", + ), + ] + + +class TestCaseOp13_Merge_ConstLock_Depth3_MidRestatesLeafViolates(HttpRunner): + """ADR-0004: const lock is enforced across a 3-level chain. + + Base locks `indexed` with `const: true`. The mid restates `indexed: true` + (idempotent, allowed). The leaf attempts `indexed: false`, which violates the + const constraint carried in the effective trait-schema. Validating the mid + passes; validating the leaf fails. Extends the 2-level const cases to depth 3. + """ + + config = Config("OP#13 ADR-0004: const lock enforced at depth 3").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mconst3.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"indexed": {"const": True}}, + "required": ["indexed"], + }, + "x-gts-traits": {"indexed": True}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base locking indexed const:true", + ), + _register_derived( + "gts://gts.x.test13.mconst3.event.v1~x.test13._.mid.v1~", + "gts://gts.x.test13.mconst3.event.v1~", + { + "type": "object", + "x-gts-traits": {"indexed": True}, + }, + "register mid restating indexed:true (idempotent)", + ), + _register_derived( + ( + "gts://gts.x.test13.mconst3.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + "gts://gts.x.test13.mconst3.event.v1~x.test13._.mid.v1~", + { + "type": "object", + "x-gts-traits": {"indexed": False}, + }, + "register leaf attempting indexed:false", + ), + _validate_type_schema( + "gts.x.test13.mconst3.event.v1~x.test13._.mid.v1~", + True, + "validate mid - idempotent restatement of the locked value", + ), + _validate_type_schema( + ( + "gts.x.test13.mconst3.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + False, + "validate leaf - indexed:false violates const at depth 3", + ), + ] + + +class TestCaseOp13_Merge_3Layer_DistinctKeysAccumulate(HttpRunner): + """ADR-0004: distinct keys set at different layers accumulate root → leaf. + + Each of three layers sets a different required trait property. RFC 7396 merge + accumulates them (no layer clobbers a key it does not mention), so the leaf's + effective traits object carries all three and satisfies the effective + trait-schema's `required`. Validates True. + """ + + config = Config("OP#13 ADR-0004: distinct keys accumulate across 3 layers").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.macc.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "a": {"type": "string"}, + "b": {"type": "string"}, + "c": {"type": "string"}, + }, + "required": ["a", "b", "c"], + }, + "x-gts-traits": {"a": "va"}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base setting a (b, c still unresolved)", + ), + _register_derived( + "gts://gts.x.test13.macc.event.v1~x.test13._.mid.v1~", + "gts://gts.x.test13.macc.event.v1~", + { + "type": "object", + "x-gts-traits": {"b": "vb"}, + }, + "register mid setting b", + top_level={"x-gts-abstract": True}, + ), + _register_derived( + ( + "gts://gts.x.test13.macc.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + "gts://gts.x.test13.macc.event.v1~x.test13._.mid.v1~", + { + "type": "object", + "x-gts-traits": {"c": "vc"}, + }, + "register leaf setting c - now a, b, c all present", + ), + _validate_type_schema( + ( + "gts.x.test13.macc.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + True, + "validate leaf - distinct keys from all layers accumulated", + ), + ] + + +class TestCaseOp13_Merge_DeleteThenReadd_AcrossLayers(HttpRunner): + """ADR-0004 / RFC 7396: a key deleted by a mid layer can be re-added by the leaf. + + Base sets `retention`; the mid null-deletes it; the leaf sets it again to a + concrete value. The leaf's restatement wins (last-wins) and the required + trait is resolved, so validation passes. Exercises delete-then-re-add merge + ordering across three layers. + """ + + config = Config("OP#13 ADR-0004: delete-then-readd across layers").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.test13.mreadd.event.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"retention": {"type": "string"}}, + "required": ["retention"], + }, + "x-gts-traits": {"retention": "P30D"}, + "required": ["id"], + "properties": {"id": {"type": "string"}}, + }, + "register base with retention P30D", + ), + _register_derived( + "gts://gts.x.test13.mreadd.event.v1~x.test13._.mid.v1~", + "gts://gts.x.test13.mreadd.event.v1~", + { + "type": "object", + "x-gts-traits": {"retention": None}, + }, + "register abstract mid deleting retention", + top_level={"x-gts-abstract": True}, + ), + _register_derived( + ( + "gts://gts.x.test13.mreadd.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + "gts://gts.x.test13.mreadd.event.v1~x.test13._.mid.v1~", + { + "type": "object", + "x-gts-traits": {"retention": "P90D"}, + }, + "register leaf re-adding retention P90D", + ), + _validate_type_schema( + ( + "gts.x.test13.mreadd.event.v1~" + "x.test13._.mid.v1~x.test13._.leaf.v1~" + ), + True, + "validate leaf - retention re-added after mid deleted it", + ), + ] + diff --git a/tests/test_refimpl_x_gts_final_abstract.py b/tests/test_refimpl_x_gts_final_abstract.py index e02a057..51d65f0 100644 --- a/tests/test_refimpl_x_gts_final_abstract.py +++ b/tests/test_refimpl_x_gts_final_abstract.py @@ -711,10 +711,14 @@ def test_start(self): class TestCaseFinal_InsideAllOfRejected(HttpRunner): - """x-gts-final inside allOf MUST be rejected — keyword must be at top level. - - The keyword is placed inside an allOf entry instead of at the schema - top level. Registration with validation should reject this. + """x-gts-final inside allOf MUST be rejected — modifier belongs on the type, not on a subschema. + + Normative basis: README §9.11.2 item 5 ("Keyword placement") — x-gts-final + MUST appear at the top level, NOT inside the allOf entries. (ADR-0001 frames + derivation form as dialect-agnostic, so the body shape is otherwise free; the + placement rule for the modifier itself lives in §9.11.2(5).) Placing it inside + an allOf entry attaches it to a subschema; registration with validation MUST + reject this. """ config = Config("final: inside allOf rejected").base_url(get_gts_base_url()) @@ -752,7 +756,13 @@ def test_start(self): class TestCaseAbstract_InsideAllOfRejected(HttpRunner): - """x-gts-abstract inside allOf MUST be rejected — keyword must be at top level.""" + """x-gts-abstract inside allOf MUST be rejected — modifier belongs on the type, not on a subschema. + + Normative basis: README §9.11.3 item 6 ("Keyword placement") — x-gts-abstract, + like x-gts-final, is a type-level modifier and MUST appear at the top level, + NOT inside an allOf entry. Placing it inside an allOf subschema is a + misplacement and MUST be rejected on registration (with validation). + """ config = Config("abstract: inside allOf rejected").base_url(get_gts_base_url()) @@ -872,8 +882,9 @@ def test_start(self): class TestCaseInteraction_FinalWithTraitsMissing(HttpRunner): """Final type with unresolved required traits — validation fails. - A final type cannot delegate trait resolution to descendants, - so all required traits without defaults MUST be provided. + Corollary of ADR-0003: trait completeness applies to non-abstract types; + final types are non-abstract by definition, so all required traits + without defaults MUST be resolved at the final type itself. """ config = Config("interaction: final with missing traits").base_url(get_gts_base_url()) @@ -918,8 +929,9 @@ def test_start(self): class TestCaseInteraction_AbstractWithIncompleteTraitsOk(HttpRunner): """Abstract type with unresolved traits — validation passes. - Abstract types are not leaf schemas, so trait resolution completeness - is not enforced on them. + Per ADR-0003, trait completeness is enforced on non-abstract types + (x-gts-abstract != true). Abstract types skip the check; descendants + are expected to close any gaps. """ config = Config("interaction: abstract with incomplete traits ok").base_url(get_gts_base_url()) diff --git a/tests/test_xgts_keyword_placement.py b/tests/test_xgts_keyword_placement.py new file mode 100644 index 0000000..75d8b72 --- /dev/null +++ b/tests/test_xgts_keyword_placement.py @@ -0,0 +1,327 @@ +"""Document-level x-gts-* keyword placement — §9.7.1/§9.11. + +All four document-level keywords describe the GTS Type as a whole and MUST +appear at the **top level** of the GTS Type Schema document (adjacent to +`$id` / `$schema`): + +- x-gts-final +- x-gts-abstract +- x-gts-traits-schema +- x-gts-traits + +A keyword nested inside any subschema (an `allOf`/`anyOf`/… entry, a +`properties` value, a `$defs` entry, …) is a **misplacement** and MUST be +rejected (fail fast) on registration when validation is enabled +(`?validate=true`), per §9.7.1 / §9.11.5. The implementation MUST NOT silently +ignore it. + +These tests assert that: +- the four keywords are accepted at the top level (positive control); +- each keyword nested inside an `allOf` entry is rejected (422); +- the rule applies to *any* subschema, not just `allOf` — modifiers nested in + `properties` / `$defs` and traits nested in `properties` are also rejected. + +Note: the rule constrains only the *position* of the keyword, not the +*contents* of `x-gts-traits-schema` (which is an ordinary JSON Schema subschema +and may freely use `$ref` / `allOf` internally — covered by OP#13 tests). +""" + +from .conftest import get_gts_base_url +from .helpers.http_run_helpers import ( + register as _register, +) +from httprunner import HttpRunner, Config, Step, RunRequest + +_SCHEMA = "http://json-schema.org/draft-07/schema#" + + +# --------------------------------------------------------------------------- +# x-gts-traits-schema / x-gts-traits — positive control (top-level accepted) +# --------------------------------------------------------------------------- + + +class TestCaseTraits_TopLevelAccepted(HttpRunner): + """x-gts-traits-schema (base) and x-gts-traits (derived) at the top level are accepted. + + Positive control for §9.7.1/§9.11: correct top-level placement must register + cleanly even with validation enabled. + """ + + config = Config("placement: traits top-level accepted").base_url(get_gts_base_url()) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.testkp.traitsok.base.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": { + "topicRef": {"type": "string"}, + "retention": {"type": "string"}, + }, + }, + "properties": {"id": {"type": "string"}}, + }, + "register base with x-gts-traits-schema at top level", + ), + Step( + RunRequest("register derived with x-gts-traits at top level should be accepted") + .post("/entities") + .with_params(**{"validate": "true"}) + .with_json({ + "$$id": "gts://gts.x.testkp.traitsok.base.v1~x.testkp._.derived.v1~", + "$$schema": _SCHEMA, + "type": "object", + "x-gts-traits": { + "topicRef": "gts.x.core.events.topic.v1~x.testkp._.orders.v1", + "retention": "P90D", + }, + "allOf": [ + {"$$ref": "gts://gts.x.testkp.traitsok.base.v1~"}, + ], + }) + .validate() + .assert_equal("status_code", 200) + ), + ] + + +# --------------------------------------------------------------------------- +# x-gts-traits — misplacement rejected +# --------------------------------------------------------------------------- + + +class TestCaseTraits_InsideAllOfRejected(HttpRunner): + """x-gts-traits inside an allOf entry MUST be rejected (§9.7.1/§9.11). + + x-gts-traits is a type-level keyword; placing it inside an allOf subschema + attaches it to a subschema rather than the type. Registration with + validation MUST reject this rather than silently dropping the trait values. + """ + + config = Config("placement: x-gts-traits inside allOf rejected").base_url(get_gts_base_url()) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.testkp.traitsallof.base.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + }, + "properties": {"id": {"type": "string"}}, + }, + "register base with x-gts-traits-schema", + ), + Step( + RunRequest("register derived with x-gts-traits inside allOf should be rejected") + .post("/entities") + .with_params(**{"validate": "true"}) + .with_json({ + "$$id": "gts://gts.x.testkp.traitsallof.base.v1~x.testkp._.derived.v1~", + "$$schema": _SCHEMA, + "type": "object", + "allOf": [ + {"$$ref": "gts://gts.x.testkp.traitsallof.base.v1~"}, + { + "type": "object", + "x-gts-traits": { + "topicRef": "gts.x.core.events.topic.v1~x.testkp._.orders.v1", + }, + }, + ], + }) + .validate() + .assert_equal("status_code", 422) + ), + ] + + +class TestCaseTraits_InsidePropertiesRejected(HttpRunner): + """x-gts-traits nested inside a `properties` subschema MUST be rejected (§9.7.1/§9.11). + + Confirms the rule is about *any* subschema, not specifically allOf: a + keyword buried inside a property's subschema is still a misplacement. + """ + + config = Config("placement: x-gts-traits inside properties rejected").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.testkp.traitsprop.base.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + }, + "properties": {"id": {"type": "string"}}, + }, + "register base with x-gts-traits-schema", + ), + Step( + RunRequest("register derived with x-gts-traits inside a property should be rejected") + .post("/entities") + .with_params(**{"validate": "true"}) + .with_json({ + "$$id": "gts://gts.x.testkp.traitsprop.base.v1~x.testkp._.derived.v1~", + "$$schema": _SCHEMA, + "type": "object", + "allOf": [{"$$ref": "gts://gts.x.testkp.traitsprop.base.v1~"}], + "properties": { + "nested": { + "type": "object", + "x-gts-traits": { + "topicRef": "gts.x.core.events.topic.v1~x.testkp._.orders.v1", + }, + }, + }, + }) + .validate() + .assert_equal("status_code", 422) + ), + ] + + +# --------------------------------------------------------------------------- +# x-gts-traits-schema — misplacement rejected +# --------------------------------------------------------------------------- + + +class TestCaseTraitsSchema_InsideAllOfRejected(HttpRunner): + """x-gts-traits-schema inside an allOf entry MUST be rejected (§9.7.1/§9.11). + + A descendant declaring a new trait shape must place x-gts-traits-schema at + the document top level, not inside an allOf subschema. (This constrains the + *position* of the keyword; the registry still chain-aggregates top-level + x-gts-traits-schema declarations via allOf per §9.7.5.) + """ + + config = Config("placement: x-gts-traits-schema inside allOf rejected").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + _register( + "gts://gts.x.testkp.tschemaallof.base.v1~", + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"topicRef": {"type": "string"}}, + }, + "properties": {"id": {"type": "string"}}, + }, + "register base with x-gts-traits-schema", + ), + Step( + RunRequest("register derived with x-gts-traits-schema inside allOf should be rejected") + .post("/entities") + .with_params(**{"validate": "true"}) + .with_json({ + "$$id": "gts://gts.x.testkp.tschemaallof.base.v1~x.testkp._.derived.v1~", + "$$schema": _SCHEMA, + "type": "object", + "allOf": [ + {"$$ref": "gts://gts.x.testkp.tschemaallof.base.v1~"}, + { + "type": "object", + "x-gts-traits-schema": { + "type": "object", + "properties": {"auditRetention": {"type": "string"}}, + }, + }, + ], + }) + .validate() + .assert_equal("status_code", 422) + ), + ] + + +# --------------------------------------------------------------------------- +# x-gts-final / x-gts-abstract — misplacement in NON-allOf subschemas +# (allOf cases live in test_refimpl_x_gts_final_abstract.py; these extend the +# coverage to other subschema positions to exercise the "any subschema" rule.) +# --------------------------------------------------------------------------- + + +class TestCaseFinal_InsidePropertiesRejected(HttpRunner): + """x-gts-final nested inside a `properties` subschema MUST be rejected (§9.7.1/§9.11).""" + + config = Config("placement: x-gts-final inside properties rejected").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + Step( + RunRequest("register schema with x-gts-final inside a property should be rejected") + .post("/entities") + .with_params(**{"validate": "true"}) + .with_json({ + "$$id": "gts://gts.x.testkp.finalprop.base.v1~", + "$$schema": _SCHEMA, + "type": "object", + "properties": { + "nested": { + "type": "object", + "x-gts-final": True, + }, + }, + }) + .validate() + .assert_equal("status_code", 422) + ), + ] + + +class TestCaseAbstract_InsideDefsRejected(HttpRunner): + """x-gts-abstract nested inside a `definitions` entry MUST be rejected (§9.7.1/§9.11).""" + + config = Config("placement: x-gts-abstract inside definitions rejected").base_url( + get_gts_base_url() + ) + + def test_start(self): + super().test_start() + + teststeps = [ + Step( + RunRequest("register schema with x-gts-abstract inside definitions should be rejected") + .post("/entities") + .with_params(**{"validate": "true"}) + .with_json({ + "$$id": "gts://gts.x.testkp.absdefs.base.v1~", + "$$schema": _SCHEMA, + "type": "object", + "properties": {"id": {"type": "string"}}, + "definitions": { + "Sub": { + "type": "object", + "x-gts-abstract": True, + }, + }, + }) + .validate() + .assert_equal("status_code", 422) + ), + ] From a25de85bd1d8940932672f2253a18ee66b8dd8b4 Mon Sep 17 00:00:00 2001 From: Aviator 5 Date: Fri, 29 May 2026 10:05:35 +0300 Subject: [PATCH 7/7] test(spec): treat allOf additionalProperties as composition, not loosening - Reframe two OP#12 additionalProperties cases to expect acceptance, since draft-07 allOf is a conjunction that composes rather than merges branches. - AP:true in a derived overlay cannot override the base branch's AP:false, so the combined schema stays closed and is not loosening. - Omitting AP in a derived schema inherits closedness via the $ref half of the allOf, so OP#12 must accept it. - Document the draft-07 semantics in each test docstring. Signed-off-by: Aviator 5 --- tests/helpers/http_run_helpers.py | 20 ++++++---- tests/test_op12_type_derivation_validation.py | 40 ++++++++++++++----- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/tests/helpers/http_run_helpers.py b/tests/helpers/http_run_helpers.py index 25aec6a..c24be49 100644 --- a/tests/helpers/http_run_helpers.py +++ b/tests/helpers/http_run_helpers.py @@ -10,9 +10,9 @@ def register(gts_id, schema_body, label="register schema"): """Register a schema via POST /entities.""" body = { + **schema_body, "$$id": gts_id, "$$schema": "http://json-schema.org/draft-07/schema#", - **schema_body, } return Step( RunRequest(label) @@ -36,11 +36,15 @@ def register_derived(gts_id, base_ref, overlay, label="register derived", top_le the spec-correct placement without restating every call site. """ overlay = dict(overlay) - hoisted = { - kw: overlay.pop(kw) - for kw in ("x-gts-traits", "x-gts-traits-schema") - if kw in overlay - } + trait_kws = ("x-gts-traits", "x-gts-traits-schema") + hoisted = {kw: overlay.pop(kw) for kw in trait_kws if kw in overlay} + if top_level: + clobbered = [kw for kw in trait_kws if kw in top_level] + if clobbered: + raise ValueError( + "top_level must not contain trait keywords " + f"{clobbered}; pass them in `overlay` so they are hoisted" + ) body = { "$$id": gts_id, "$$schema": "http://json-schema.org/draft-07/schema#", @@ -78,9 +82,9 @@ def register_derived_redeclared( top_level: optional dict merged into body at the top level. """ full = { + **body, "$$id": gts_id, "$$schema": "http://json-schema.org/draft-07/schema#", - **body, } if top_level: full.update(top_level) @@ -100,9 +104,9 @@ def register_abstract(gts_id, schema_body, label="register abstract"): /validate-type-schema time. """ body = { + **schema_body, "$$id": gts_id, "$$schema": "http://json-schema.org/draft-07/schema#", - **schema_body, "x-gts-abstract": True, } return Step( diff --git a/tests/test_op12_type_derivation_validation.py b/tests/test_op12_type_derivation_validation.py index e6002cd..fdd1449 100644 --- a/tests/test_op12_type_derivation_validation.py +++ b/tests/test_op12_type_derivation_validation.py @@ -2188,9 +2188,20 @@ def test_start(self): extra_base={"items": {"type": "string"}}) -class TestCaseTestOp12_AdditionalPropertiesLoosened(HttpRunner): - """OP#12 - Base AP false, derived sets AP true → must fail.""" - config = Config("OP#12 - additionalProperties Loosened").base_url( +class TestCaseTestOp12_AdditionalPropertiesTrueInOverlayStaysClosed(HttpRunner): + """OP#12 - Base AP false; derived overlay sets AP true → still closed, passes. + + The derived schema is `allOf: [{$ref: closed_base}, {…, AP: true}]`. + Under JSON Schema draft-07, allOf is a conjunction: an instance must + validate against *every* branch. The base branch (AP:false over its + own `properties`) independently rejects any extra top-level key, and + the overlay's `additionalProperties: true` cannot override another + branch's constraint — allOf composes, it does not merge. The combined + schema therefore remains closed, so this is **not** loosening and OP#12 + must accept it. Flagging it would require structural intent-detection + rather than effective-instance semantics. + """ + config = Config("OP#12 - additionalProperties true in overlay stays closed").base_url( get_gts_base_url()) def test_start(self): @@ -2211,18 +2222,29 @@ def test_start(self): "properties": {"id": {"type": "string"}}, "additionalProperties": True, }, - "register derived setting AP true", + "register derived with AP true in overlay", ), _validate_type_schema( "gts.x.test12.ap.loose.v1~x.test12._.opened.v1~", - False, "validate should fail - AP loosened", + True, "validate should pass - base branch still denies extras via allOf", ), ] -class TestCaseTestOp12_AdditionalPropertiesOmitted(HttpRunner): - """OP#12 - Base AP false, derived omits AP → must fail.""" - config = Config("OP#12 - additionalProperties Omitted").base_url( +class TestCaseTestOp12_AdditionalPropertiesOmittedInheritsClosedness(HttpRunner): + """OP#12 - Base AP false, derived omits AP → inherits closedness, passes. + + The derived schema is `allOf: [{$ref: closed_base}, overlay]` and omits + `additionalProperties` at its own root. Per draft-07 § 6.5.6, + `additionalProperties` only inspects sibling `properties` at the same + level — but the base's `additionalProperties: false` still applies to + the same instance through the `$ref` half of the allOf composition. + The closedness is therefore *inherited*; omitting the keyword is not + loosening, and OP#12 must accept this shape. (A derived schema that + wants to genuinely open up cannot do so via allOf at all — see + TestCaseTestOp12_AdditionalPropertiesTrueInOverlayStaysClosed.) + """ + config = Config("OP#12 - additionalProperties omitted inherits closedness").base_url( get_gts_base_url()) def test_start(self): @@ -2246,7 +2268,7 @@ def test_start(self): ), _validate_type_schema( "gts.x.test12.ap.omit.v1~x.test12._.no_ap.v1~", - False, "validate should fail - AP omitted", + True, "validate should pass - closedness inherited via $ref/allOf", ), ]