diff --git a/docs/codegen/README.md b/docs/codegen/README.md new file mode 100644 index 00000000..f4a18dcf --- /dev/null +++ b/docs/codegen/README.md @@ -0,0 +1,40 @@ +# Code-generation design specs + +This directory holds **design specifications** for a future code generator that emits per-API +SDKs on top of the hand-written `sdk-core` toolkit. Nothing here is built yet, and nothing here +ships as part of `sdk-core`. + +Two ground rules apply to every spec in this directory: + +1. **`sdk-core` stays a toolkit, not a generator.** No KotlinPoet, no generator runtime, and no + schema/validation library ever lands in `sdk-core` or any published toolkit module. Anything a + generated SDK needs at runtime is expressed in terms of types `sdk-core` already exposes + (`Serde`, `Tristate`, `RequestBody`, `Paginator`, the context chain, `HttpClient` / + `AsyncHttpClient`, `HttpPipeline`, `Io` / `IoProvider`). +2. **Generated artifacts are physically separate from hand-written code.** A generator writes into + a generated SDK's own module(s); it never edits the toolkit. Any code-shaped snippet in these + docs is *target generator output*, labelled as such — it is not compiled in this repository. + +## Specs + +| Spec | Topic | +|---|---| +| [strict-structured-output-schema.md](strict-structured-output-schema.md) | Strict JSON-schema encoding rules for structured outputs: all-required + `additionalProperties:false` + optional-as-nullable-union. Adapter-only derivation, hand-rolled subset validator. | +| [fail-soft-validator-skeleton.md](fail-soft-validator-skeleton.md) | Design of a reusable fail-soft recursive validator skeleton for generator output (path-prefixed error collection, recursion guard, deterministic definition names). Deferred — design only, no runtime type today. | +| [generated-sdk-provenance.md](generated-sdk-provenance.md) | Provenance file stamped into generated SDKs: generator version + input-contract hash, format, and location. Generated output only. | +| [spring-boot-starter.md](spring-boot-starter.md) | Per-API Spring Boot starter shape: `@ConfigurationProperties`, a `fun interface` customizer, and an `@AutoConfiguration` bean assembling `{IoProvider + transport + HttpPipeline}`. Spring deps confined to the generated starter. | + +## Grounding in `sdk-core` + +Each spec cites the real runtime types it builds on so that the eventual generator targets the +current API rather than an invented one. The most-referenced anchors: + +- **`org.dexpace.sdk.core.serde`** — `Serde`, `Serializer`, `Deserializer`, and `Tristate` + (the three-state container for `absent` / `null` / `present` PATCH fields). +- **`org.dexpace.sdk.core.client`** — `HttpClient` / `AsyncHttpClient` transport SPIs. +- **`org.dexpace.sdk.core.http.pipeline`** — `HttpPipeline` / `HttpPipelineBuilder` and the + stage-ordered `HttpStep` model. +- **`org.dexpace.sdk.core.io`** — the `Io.installProvider(...)` seam and `IoProvider`. +- **`org.dexpace.sdk.core.pagination`** — `Paginator` and its `PaginationStrategy`. +- **`org.dexpace.sdk.core.http.context`** — the `CallContext` → `DispatchContext` → + `RequestContext` → `ExchangeContext` promotion chain. diff --git a/docs/codegen/fail-soft-validator-skeleton.md b/docs/codegen/fail-soft-validator-skeleton.md new file mode 100644 index 00000000..dcb8babb --- /dev/null +++ b/docs/codegen/fail-soft-validator-skeleton.md @@ -0,0 +1,113 @@ +# Fail-soft recursive validator skeleton + +Status: design spec, **deferred**. Closes #67. + +> This captures the *design* of a validator idiom for generator output. There is **no runtime type +> for it today** and none is added by this document. It is built only when the code generator +> exists; until then it lives here as a shape to implement against. Like all codegen runtime, it +> would land in a generator/adapter module, never in `sdk-core`. + +## Problem + +Validators over a spec or schema tree — "is this input contract well-formed?", "is this derived +strict schema in the allowed subset?" (see +[strict-structured-output-schema.md](strict-structured-output-schema.md)) — want a consistent +**fail-soft** shape: + +- collect **all** problems with a path prefix, rather than throwing on the first one, so a user + fixing a contract sees every error in one pass; +- guard against **cyclic** trees (a `$ref` chain that loops) so recursion terminates; +- name definitions **deterministically** so two distinct nodes with the same simple name do not + alias each other in error messages or in a visited-set. + +The idiom is tiny — on the order of fifteen lines of recursion — but it has to be the *same* tiny +idiom everywhere a validator is written, so that error output and cycle handling are uniform. + +## The skeleton + +Parameterised over **our own tree type**, not a third-party node model. The generator already has a +normalized in-memory representation of the input contract; the validator walks that. The shape: + +```kotlin +// DESIGN ONLY — not built today; would live in a generator module, never sdk-core. + +/** One problem, addressed by a slash-joined path from the tree root. */ +data class ValidationError(val path: String, val message: String) + +class Validator(private val errors: MutableList = mutableListOf()) { + + // Recursion guard keyed by deterministic node id — see "Deterministic definition names". + private val visiting = HashSet() + + /** Verify [cond]; on failure record a path-prefixed error and signal "stop this branch". */ + private inline fun verify(cond: Boolean, path: String, message: () -> String): Boolean { + if (!cond) errors += ValidationError(path, message()) + return cond + } + + fun validate(node: Node, path: String = "") { + val id = node.definitionName // FQN/deterministic, never the simple name + if (!visiting.add(id)) return // one-shot recursion guard: already on this branch + try { + // early-return verify helper: a failed precondition stops descent into a broken node, + // but earlier siblings' errors are already collected. + if (!verify(node.isWellFormed, path) { "malformed node '$id'" }) return + for ((key, child) in node.children) { + validate(child, if (path.isEmpty()) key else "$path/$key") + } + } finally { + visiting.remove(id) // pop on the way out so siblings can revisit shared defs + } + } + + fun result(): List = errors.toList() +} +``` + +Three load-bearing pieces, matching the issue's acceptance criteria: + +1. **One-shot recursion guard.** `visiting.add(id)` returns `false` if `id` is already on the + current descent path; we return immediately, so a cyclic `$ref` cannot loop forever. The guard is + popped in `finally` so the same shared definition can be re-entered down a *different* branch + (we guard against cycles, not against repeated visits). +2. **Path-prefixed error list.** Every `verify` failure is recorded as `(path, message)` and + appended; nothing throws mid-walk. The caller gets the full list from `result()` and decides + whether a non-empty list is fatal. +3. **Early-return `verify` helper.** `verify` both records the error *and* returns the boolean, so a + call site can `if (!verify(...)) return` to stop descending into a node that is too broken to walk + while still having collected the error and everything found before it. + +## Deterministic definition names + +`definitionName` must be a **fully-qualified, deterministic** identifier — the package-qualified +type name plus a stable mangling for generic arguments — never the bare simple name. This matters in +two places: + +- **The recursion guard.** Two unrelated nodes that happen to share a simple name (`Metadata` in two + packages) must hash to *different* ids, or the guard would wrongly treat the second as a cycle of + the first and skip it. +- **Error messages.** `"malformed node 'com.example.a.Metadata'"` is actionable; `"malformed node + 'Metadata'"` is ambiguous across a large API surface. + +This is the same deterministic-name rule the strict-schema spec applies to `$defs` keys +([strict-structured-output-schema.md](strict-structured-output-schema.md) §R6), so a single naming +function serves both the schema derivation and its validator. + +## Why fail-soft (decisions / trade-offs) + +- **All errors in one pass.** Throwing on the first problem forces an edit-rerun-edit loop over a + contract; collecting every error lets a user fix a batch at once. The cost is that the walk must be + defensive — hence the early-return helper, so a broken node does not cause a cascade of spurious + child errors. +- **Our tree type, not a generic JSON model.** Validating the generator's own normalized model keeps + the validator decoupled from whatever parser produced the contract and lets the same skeleton check + both input contracts and derived strict schemas. +- **No library.** The skeleton is small enough to hand-write and keeps shipped modules free of a + schema/validation dependency, consistent with the rest of the codegen design. + +## Acceptance mapping + +- *Validator skeleton* — the `Validator` shape above (recursion guard + path-prefixed list + + early-return `verify`). +- *Deterministic definition names* — `definitionName` is FQN/deterministic, used for both the guard + and error messages; shared with the strict-schema `$defs` naming. diff --git a/docs/codegen/generated-sdk-provenance.md b/docs/codegen/generated-sdk-provenance.md new file mode 100644 index 00000000..c7a2e273 --- /dev/null +++ b/docs/codegen/generated-sdk-provenance.md @@ -0,0 +1,104 @@ +# Generated-SDK provenance file + +Status: design spec. Closes #68. + +## Problem + +A generated SDK is a build artifact: the same input contract run through a different generator +version can produce different output. When a bug is reported against a generated SDK, the first two +questions are always "which generator produced this?" and "from which input contract?". Without a +recorded answer, the only way to reproduce is to guess the generator version and re-derive — which is +exactly the situation provenance metadata exists to avoid. + +This spec defines a small, machine-readable **provenance file** stamped into generated output. + +## Scope: generated output only + +The provenance file is written **only into generated SDKs, never into the hand-written toolkit.** +`sdk-core` and the other published toolkit modules are authored by hand and version themselves +through `gradle.properties` (`group` / `version`); stamping a generator provenance file into them +would be meaningless and is forbidden. The generator writes the file into the SDK module it emits and +touches nothing else. + +## What metadata + +| Field | Meaning | +|---|---| +| `generatorName` | Stable identifier of the generator (e.g. `org.dexpace:sdk-codegen`). | +| `generatorVersion` | Exact released version of the generator that produced this SDK. | +| `inputContractHash` | Content hash of the **normalized** input contract (OpenAPI / JSON-schema), `sha256:` prefixed. Normalization (sort keys, strip insignificant whitespace) so semantically-identical contracts hash identically regardless of formatting. | +| `inputContractName` | Human-readable contract identifier (e.g. the API title + version from the OpenAPI `info` block). | +| `generatedAt` | ISO-8601 UTC timestamp of generation. Informational only — it is **excluded** from any reproducibility comparison (see below). | +| `schemaVersion` | Version of this provenance-file format itself, so consumers can parse older stamps. | + +`generatorVersion` + `inputContractHash` together are the reproducibility key: same generator +version + same normalized contract hash ⇒ byte-identical SDK (modulo the timestamp). `generatedAt` is +deliberately **not** part of that key, so reproducibility checks compare everything except the +timestamp. + +## Format + +JSON, so it is trivially machine-readable and round-trips through the existing `Serde` / +`Deserializer` SPI at runtime without any new dependency — a tool can read it back with +`Deserializer.deserialize(json, GeneratedProvenance::class.java)` using the toolkit's own serde, with +no schema/provenance library involved. + +Target output: + +```json +{ + "schemaVersion": "1", + "generatorName": "org.dexpace:sdk-codegen", + "generatorVersion": "0.4.2", + "inputContractName": "Example API 2025-11", + "inputContractHash": "sha256:9f2b1c…", + "generatedAt": "2026-06-17T09:30:00Z" +} +``` + +## Location + +Two copies, serving two different consumers: + +1. **On the classpath, as a resource:** + `META-INF/dexpace//generated-provenance.json` in the generated module's + `src/main/resources` (so it ships inside the published jar). Programmatic access: + + ```kotlin + // GENERATED accessor — illustrative target output, not compiled here. + public object GeneratedProvenance { + public fun read(): String = + checkNotNull( + javaClass.getResourceAsStream( + "/META-INF/dexpace/example-api/generated-provenance.json", + ), + ).bufferedReader().use { it.readText() } + } + ``` + + Namespacing under `META-INF/dexpace//` keeps multiple generated SDKs on one classpath + from clobbering each other's stamp. + +2. **At the generated source root**, as a checked-in `PROVENANCE.json`, so the metadata is visible in + the SDK's repository / diff without unpacking a jar. Both copies are written from the same values + in one pass, so they cannot drift. + +## Decisions / trade-offs + +- **Normalized hash, not raw-bytes hash.** A reformatted-but-equivalent contract should not look like + a different input. Hashing the normalized form makes the reproducibility key robust to cosmetic + changes. +- **Timestamp present but excluded from the key.** Engineers want to know *when* an SDK was cut; + reproducibility checks must not be defeated by that timestamp. Keeping `generatedAt` informational + satisfies both. +- **JSON over `.properties` or a manifest entry.** JSON nests cleanly (room for future fields like a + list of source files or a transport matrix) and reuses the toolkit's existing serde, so no new + parsing dependency is introduced. +- **Resource + source copy.** The resource serves runtime/diagnostic code; the source copy serves + humans reading the generated repo. One write pass, two destinations, no drift. + +## Acceptance mapping + +- *Provenance stamped in generated output* — `generatorVersion` + `inputContractHash` (plus + supporting fields) written to `META-INF/dexpace//generated-provenance.json` and a + source-root `PROVENANCE.json`, in generated output only. diff --git a/docs/codegen/spring-boot-starter.md b/docs/codegen/spring-boot-starter.md new file mode 100644 index 00000000..25da1214 --- /dev/null +++ b/docs/codegen/spring-boot-starter.md @@ -0,0 +1,160 @@ +# Per-API Spring Boot starter + +Status: design spec. Closes #69. + +> Design note for the **generator**. We do **not** build a toolkit-level starter now. The toolkit +> (`sdk-core` and friends) stays framework-agnostic; Spring is a generated-output concern. Spring +> dependencies are confined to the **generated starter module** and never reach any toolkit module. +> Prior art: openai-java ships a per-SDK `spring-boot-starter` module of exactly this shape. + +Related: #60 (`withOptions` / unified client config) — the customizer hook below is the Spring-facing +face of that unified config. + +## Problem + +Spring Boot users expect to add one starter dependency, set a couple of properties, and get an +autoconfigured, injectable client — without writing the wiring that installs an `IoProvider`, picks a +transport, and assembles a pipeline by hand. A generated SDK that offers no autoconfiguration forces +every Spring consumer to re-derive the same bean graph. + +The generator therefore emits, **per generated API**, a small starter module with three pieces: + +1. a `@ConfigurationProperties` class binding `application.yml` properties, +2. a `fun interface` client customizer so users can adjust the client before it is built, and +3. an `@AutoConfiguration` that assembles the client bean, backing off if the user already defined + one. + +## The bean it assembles + +The autoconfiguration's job is to stand up the same object graph a manual user would build, from +`sdk-core` primitives: + +- **`IoProvider`** — installed once via `Io.installProvider(...)` (the `sdk-io-okio3` + `OkioIoProvider` by default). `Io.installProvider` is idempotent for the same instance and throws + on a conflicting double-install, so the autoconfiguration installs exactly once at bean-creation + time. +- **Transport** — an `HttpClient` (or `AsyncHttpClient`) from a transport module + (`sdk-transport-okhttp` / `sdk-transport-jdkhttp`). The starter owns the one it creates and lets + the transport's own `close()` release it; a user-supplied client is never closed by the starter + (the transport SPIs already encode this ownership rule). +- **`HttpPipeline`** — built with `HttpPipelineBuilder(httpClient)`, wiring the standard pillar + stages (retry / auth / logging / serde) from the bound properties. + +The generated top-level client wraps that pipeline. The customizer (below) runs against the builder +just before `build()`, so users can append steps or swap a pillar. + +## Properties + +```kotlin +// GENERATED — illustrative target output for the "Example" API; not compiled here. +@ConfigurationProperties(prefix = "dexpace.example") +public data class ExampleClientProperties( + /** Base URL of the API. */ + var baseUrl: String = "https://api.example.com", + /** Bearer / API-key credential; bound from config or an env var. */ + var apiKey: String? = null, + /** Per-request timeout. */ + var timeout: Duration = Duration.ofSeconds(30), + /** Max retry attempts for the retry pillar. */ + var maxRetries: Int = 2, +) +``` + +Bound from, e.g.: + +```yaml +dexpace: + example: + base-url: https://api.example.com + api-key: ${EXAMPLE_API_KEY} + timeout: 30s + max-retries: 2 +``` + +Property names intentionally echo the toolkit's existing `Configuration` keys (e.g. +`MAX_RETRY_ATTEMPTS`, `LOG_LEVEL`) so the Spring binding and the plain-`Configuration` path stay +recognizably the same knobs. + +## Customizer + +A `fun interface` (single-abstract-method, so it is a clean Java/Kotlin lambda target) that receives +the `HttpPipelineBuilder` before the pipeline is built: + +```kotlin +// GENERATED — illustrative target output; not compiled here. +public fun interface ExampleClientCustomizer { + /** Adjust the pipeline builder before the client is assembled. */ + public fun customize(builder: HttpPipelineBuilder) +} +``` + +This is the extension seam: a user defines a `@Bean ExampleClientCustomizer { builder -> ... }` to +append an `HttpStep`, replace a pillar (`builder.replace<...>()`), or attach instrumentation, without +forking the autoconfiguration. It is the Spring-facing surface of the unified client config tracked +in #60 — Spring users reach the same configuration through a bean instead of a builder call. + +## Autoconfiguration + +```kotlin +// GENERATED — illustrative target output; not compiled here. +@AutoConfiguration +@EnableConfigurationProperties(ExampleClientProperties::class) +public class ExampleClientAutoConfiguration { + + @Bean + @ConditionalOnMissingBean + public fun exampleClient( + properties: ExampleClientProperties, + customizers: ObjectProvider, + ): ExampleClient { + Io.installProvider(OkioIoProvider) // idempotent; once per JVM + + val transport: HttpClient = OkHttpHttpClient.builder() // starter-owned; closed by transport + .baseUrl(properties.baseUrl) + .callTimeout(properties.timeout) + .build() + + val builder = HttpPipelineBuilder(transport) + .append(/* auth pillar from properties.apiKey */) + .append(/* retry pillar from properties.maxRetries */) + .append(/* logging + serde pillars */) + + customizers.orderedStream().forEach { it.customize(builder) } + + return ExampleClient(builder.build()) + } +} +``` + +`@ConditionalOnMissingBean` is the key decision: if the user has already declared their own +`ExampleClient` bean (fully manual wiring), the starter backs off entirely. The starter provides a +default, never a mandate. Registration is via the Spring Boot 3 +`META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` file in the +generated starter module. + +## Module layout + +The starter is its **own generated module**, e.g. `example-sdk-spring-boot-starter`, depending on the +generated `example-sdk`, a transport module, an I/O module, and `spring-boot-autoconfigure`. The +Spring dependency stops there. The core generated SDK (`example-sdk`) has **no** Spring dependency, so +non-Spring users consume it untouched — mirroring openai-java's split between its core SDK and its +separate `spring-boot-starter` artifact. + +## Decisions / trade-offs + +- **`@ConditionalOnMissingBean` over an unconditional bean** — autoconfiguration must yield to + explicit user wiring; otherwise advanced users cannot fully control the client. +- **Customizer as a `fun interface`, not subclassing** — a SAM lambda is the lightest extension + point and composes (multiple customizer beans run in order) without inheritance. +- **Starter assembles `{IoProvider + transport + HttpPipeline}` only** — it does not invent new + runtime behavior; it is pure wiring over existing `sdk-core` / adapter primitives, so the toolkit + stays the single source of behavior and Spring stays a thin assembly layer. +- **Spring confined to the starter module** — keeps the generated core SDK dependency-light and + usable outside Spring, consistent with the toolkit's framework-agnostic stance. + +## Acceptance mapping + +- *Starter shape recorded for codegen* — `@ConfigurationProperties` class, `fun interface` + customizer, and `@AutoConfiguration` with `@Bean @ConditionalOnMissingBean` assembling + `{IoProvider + transport + HttpPipeline}`, all in a per-API generated starter module with Spring + deps confined there. diff --git a/docs/codegen/strict-structured-output-schema.md b/docs/codegen/strict-structured-output-schema.md new file mode 100644 index 00000000..220e50bf --- /dev/null +++ b/docs/codegen/strict-structured-output-schema.md @@ -0,0 +1,154 @@ +# Strict structured-output JSON-schema encoding + +Status: design spec. Closes #66. + +## Problem + +Some providers accept a JSON schema that *constrains* a model's structured output: the model is +forced to emit a document that validates against the schema. These "strict" modes are far less +forgiving than ordinary schema validation. They typically reject schemas unless: + +- every property of every object is listed in `required`, and +- every object sets `additionalProperties: false`, and +- optionality is expressed as a **type union with `null`**, not by omission from `required`. + +If the generator ever derives such a schema from an OpenAPI / JSON-schema input contract, it has to +emit this strict shape exactly. The naive encoding — "optional field ⇒ leave it out of `required`" +— is rejected by strict mode and silently mis-models PATCH-style optionality at runtime. This spec +fixes the mapping from a generated DTO to its strict schema, and pins where that derivation is +allowed to live. + +## Where the derivation lives + +**Adapter module only — never `sdk-core`.** `sdk-core` ships no embedded serializer and no +schema machinery (see `Serde`'s KDoc: "Concrete implementations live outside `sdk-core` since +`sdk-core` deliberately ships no embedded serializer."). Schema derivation is structurally the same +kind of concern: it reads a Jackson type model and emits a JSON document. It therefore belongs in a +Jackson-backed adapter alongside `sdk-serde-jackson`, not in the toolkit core. + +We also **do not pull a JSON-schema library.** The strict subset we emit is small and fully +determined by the DTO shape, so the adapter hand-rolls: + +1. a **derivation** pass (DTO → strict schema JSON), and +2. a **subset validator** that checks an arbitrary schema document is in the strict subset before it + is sent (see [fail-soft-validator-skeleton.md](fail-soft-validator-skeleton.md) for the validator + idiom this reuses). + +A full JSON-schema validator is out of scope; we only validate the slice we generate. + +## Encoding rules + +Given a generated DTO, the derived schema obeys all of the following. Each rule is non-negotiable +for strict mode. + +### R1 — every object is closed + +Every generated `object` schema carries `"additionalProperties": false`. No open maps are emitted +for a fixed DTO. Free-form maps (a Kotlin `Map`) are modelled with an explicit +`additionalProperties` *schema* (the `V` schema), which is a different construct and is allowed. + +### R2 — every property is required + +`required` lists **every** property key, including the ones that are logically optional. Optionality +never shows up as absence from `required`. + +### R3 — optional ⇒ nullable union + +A logically optional field is encoded as a union of its value type with `null`: + +```json +"type": ["string", "null"] +``` + +or, for a `$ref`'d sub-schema, `"anyOf": [ { "$ref": "..." }, { "type": "null" } ]`. + +### R4 — nullable and optional collapse to the same wire shape + +Strict mode cannot distinguish "absent" from "present-and-null" — both arrive as `null`. This maps +directly onto `sdk-core`'s `Tristate`: + +- `Tristate.Absent` and `Tristate.Null` both serialize, under strict mode, to `null`. +- `Tristate.Present(v)` serializes to `v`. + +The generator therefore renders a `Tristate` field and a plain nullable `T?` field to the **same** +strict schema fragment (`["", "null"]`, in `required`). The richer absent-vs-null distinction is +recovered at the runtime serde boundary by `sdk-serde-jackson`'s `TristateModule`, not in the schema. + +### R5 — enums are closed + +A Kotlin `enum class` becomes `"enum": [...]` with every constant, plus `null` in the value list if +the field is optional (R3). No `default` is emitted into a strict schema. + +### R6 — deterministic `$defs` names + +Nested object types are hoisted into `$defs` keyed by a **fully-qualified, deterministic** name +(package-qualified type name with a stable mangling for generics), never the bare simple name. Two +DTOs called `Metadata` in different packages must not collide. This mirrors the deterministic-name +rule in [fail-soft-validator-skeleton.md](fail-soft-validator-skeleton.md) §"Deterministic +definition names". + +## Worked example (target output) + +Given this generated DTO (illustrative target output, not compiled here): + +```kotlin +// GENERATED — illustrative; not part of this repo. +public data class UserPatch( + val id: String, // required value + val nickname: String?, // optional, plain nullable + val avatar: Tristate, // optional, PATCH tri-state + val role: Role, // enum, required +) + +public enum class Role { ADMIN, MEMBER, GUEST } +``` + +the adapter derives (target output): + +```json +{ + "type": "object", + "additionalProperties": false, + "required": ["id", "nickname", "avatar", "role"], + "properties": { + "id": { "type": "string" }, + "nickname": { "type": ["string", "null"] }, + "avatar": { "type": ["string", "null"] }, + "role": { + "anyOf": [ + { "$ref": "#/$defs/com.example.api.model.Role" } + ] + } + }, + "$defs": { + "com.example.api.model.Role": { + "type": "string", + "enum": ["ADMIN", "MEMBER", "GUEST"] + } + } +} +``` + +Note `nickname` (plain nullable) and `avatar` (`Tristate`) collapse to the identical fragment per +R4: both are required-and-nullable in the schema; only the runtime serde keeps `absent` and `null` +apart. + +## Trade-offs and decisions + +- **Strictness over expressiveness.** All-required + closed objects is a deliberately narrow subset. + It rejects schema features (open objects, conditional subschemas, `default`) that strict providers + reject anyway, so the narrowness is a feature: the subset validator can be a few dozen lines. +- **No schema library.** Pulling a general JSON-schema implementation would buy validation power we + do not need and a dependency the toolkit philosophy forbids in shipped modules. Hand-rolling keeps + the derivation auditable and the dependency surface flat. +- **`Tristate` is the bridge, not a schema concept.** The schema cannot encode three states, so the + generator does not try. It emits two states (`value | null`) and relies on + `sdk-serde-jackson`'s existing `Tristate` ser/de to carry the third state on the wire. +- **Deterministic names protect against silent collisions** — the most common foot-gun when many + small DTOs share simple names across an API surface. + +## Acceptance mapping + +- *Encoding rules documented* — R1–R6 above. +- *Confirmed adapter-only (no core dep)* — see "Where the derivation lives"; derivation and the + subset validator live in a Jackson adapter, `sdk-core` gains nothing.