Schema export and discovery
data.svc renders eight external schema formats and a JSON discovery surface from the
same entity definitions the engine executes. Nothing is hand-maintained: change a field,
rebuild the engine, and every format changes with it.
Export renderers are pure functions over the resolved schema — no I/O, no state, and deterministic ordering (entities and enums sorted by name, fields by declaration position with a name tie-break). The JSON-valued formats are byte-identical across calls, so they diff and cache cleanly. The Zod and CUE files stamp the generation time into a header comment, so two fetches of an unchanged schema differ on that line — strip the header before diffing.
Formats and the endpoint that serves each
Section titled “Formats and the endpoint that serves each”Paths are shown as an external client sees them through the gateway,
which strips the /data/ prefix; the service itself serves them at /schema/....
Every route accepts an optional ?datastore=<name> and falls back to the schema’s
default datastore.
| Format | Version | Bundle endpoint | Per-entity endpoint | Content type |
|---|---|---|---|---|
| OpenAPI | 3.1.0 | GET /data/schema/openapi.json | — | application/json |
| JSON Schema | 2020-12 | GET /data/schema/jsonschema.json | GET /data/schema/jsonschema/{entity} | application/schema+json |
| Zod (TypeScript) | Zod 3.22+ | GET /data/schema/zod.ts | GET /data/schema/zod/{entity} | application/typescript |
| CUE | — | GET /data/schema/cue | GET /data/schema/cue/{entity} | text/x-cue |
| Protocol Buffers | proto3 | none | none | — |
| Apache Avro | 1.11 | none | none | — |
| OData CSDL | V4 | none | none | — |
| AsyncAPI | 2.6.0 | none | none | — |
On the per-entity routes the file extension is optional — /data/schema/jsonschema/order
and /data/schema/jsonschema/order.json return the same document, and the same holds
for .ts on the Zod route and .cue on the CUE route.
All export and discovery routes require a bearer token. There is no anonymous mirror for schema discovery, unlike GraphQL, which serves an anonymous endpoint. A published anonymous integration has to be handed its schema out of band.
What each format is good for
Section titled “What each format is good for”| Format | Use it for |
|---|---|
| OpenAPI 3.1 | Generating REST clients and SDKs; import into API consoles and contract-test tooling. |
| JSON Schema 2020-12 | Request/response validation with Ajv or any 2020-12 validator; the input to most codegen chains. |
| Zod | Front-end TypeScript types and runtime validation compiled from the same definitions. |
| CUE | Config templating and policy validation pipelines that need a lattice, not a validator. |
| Protocol Buffers | Typed messages for gRPC or binary transports built alongside the API. |
| Avro | Kafka and schema-registry pipelines; the record shapes for change-feed consumers. |
| OData CSDL | Direct binding from Excel Power Query, Tableau and SAP clients. |
| AsyncAPI | An event-contract document describing entity created/updated/deleted messages. |
Three variants per entity
Section titled “Three variants per entity”Six of the eight renderers emit three shapes for each entity; OData CSDL and AsyncAPI emit the Output shape only:
| Variant | Contains | Meaning |
|---|---|---|
| Output | Every readable field, including server-managed ones | What a response body looks like |
| Input | Fields a client may send on create | What a POST body looks like |
| Patch | Input with every field optional | What a PATCH body looks like |
Entity names are PascalCased for type names — order_item becomes OrderItem — and the
variants are named per format:
| Format | Output | Input | Patch |
|---|---|---|---|
| OpenAPI | components.schemas.Order | OrderInput | OrderPatch |
| JSON Schema | $defs/Order | $defs/OrderInput | $defs/OrderPatch |
| Zod | OrderSchema | OrderInputSchema | OrderPatchSchema |
| CUE | #Order | #OrderInput | #OrderPatch |
| Protocol Buffers | message Order | message OrderInput | message OrderPatch |
| Avro | record Order | OrderInput | OrderPatch |
| OData CSDL | EntityType Order | not emitted | not emitted |
| AsyncAPI | components.schemas.Order | not emitted | not emitted |
Zod additionally exports the three inferred TypeScript types Order, OrderInput and
OrderPatch, and derives OrderPatchSchema as OrderInputSchema.partial().
Which variants a field appears in
Section titled “Which variants a field appears in”Variant membership is driven by field modifiers and the computed flag:
| Field declaration | Output | Input | Patch |
|---|---|---|---|
| no modifier | yes | yes | yes |
readonly | yes | no | no |
writeonly | no | yes | yes |
final | yes | yes | no |
computed: (virtual or materialized) | yes | no | no |
writeonce | yes | yes | yes |
writeonce is enforced at write time by the engine but has no effect on export shape.
Requiredness is per-variant: Output treats every emitted field as present, Input honours
the field’s required flag, and Patch requires nothing.
Fields that never reach an export
Section titled “Fields that never reach an export”A schema export is a data-egress surface, so one gate runs in front of every renderer’s field loop. A field is dropped from all three variants in all eight formats when its effective compliance posture marks it non-exportable — either explicitly, or implicitly because it carries a data classification.
| Declaration | Effect on exports |
|---|---|
compliances: [{type: noexport}] | Field removed from every format and variant |
compliances: [{type: pii}] | Implies noexport (and nolog); field removed |
compliances: [{type: phi}] | Implies noexport; field removed |
compliances: [{type: pci}] | Implies noexport; field removed |
compliances: [{type: gdpr}] | Implies noexport; field removed |
any other marker (mask, redact, hidden, tokenize, encrypted, hash, nolog, noindex, retention, searchable) | Field exported; marker surfaces as x-compliance in the OpenAPI-flavoured schemas |
entities: - name: customer fields: - name: email type: string - name: internal_score type: string compliances: - type: noexport - name: ssn type: string compliances: - type: piiOnly email appears in the rendered documents. internal_score is dropped by the
explicit marker and ssn by the classification.
Field-type mapping
Section titled “Field-type mapping”Text and codegen formats
Section titled “Text and codegen formats”| Field type | JSON Schema / OpenAPI | Zod | CUE |
|---|---|---|---|
string, text | string | z.string() | string |
int, bigint | integer | z.number().int() | int |
float | number | z.number() | number |
decimal, money | number | z.number() | number |
bool | boolean | z.boolean() | bool |
datetime | string, format: date-time | z.string().datetime() | time.Time |
date | string, format: date | z.string().regex(/^\d{4}-\d{2}-\d{2}$/) | string & =~ "^\\d{4}-\\d{2}-\\d{2}$" |
time | string, format: time | z.string().regex(/^\d{2}:\d{2}(:\d{2})?$/) | string & =~ "^\\d{2}:\\d{2}(:\\d{2})?$" |
uuid | string, format: uuid + pattern | z.string().uuid() | string & =~ "<uuid pattern>" |
ulid | string + ^[0-9A-HJKMNP-TV-Z]{26}$ | z.string().ulid() | string & =~ "^[0-9A-HJKMNP-TV-Z]{26}$" |
json | {} (unconstrained) | z.unknown() | _ |
enum, state | $ref: #/$defs/<Enum> | <Enum>Enum | #<Enum> |
array(<inner>) | array with items | z.array(<inner>) | [...<inner>] |
file | object {id, url, mime_type, size} | z.object({ id, url, mime_type, size }) | struct with the same four keys |
bytes | string, contentEncoding: base64 | z.string() | bytes |
Zod’s native string formats require Zod 3.22 or later. The renderer has a non-native
fallback, but it is not equivalent: uuid and ulid degrade to z.string().regex(…)
while datetime degrades to a bare z.string() and the .email(), .url() and .ip()
validations are dropped entirely. The shipped HTTP route emits the native form; the
fallback is not reachable over HTTP. date and time are always regexes — Zod has no
native helper for either.
Wire formats
Section titled “Wire formats”| Field type | Protocol Buffers | Avro | OData CSDL |
|---|---|---|---|
string, text | string | string | Edm.String |
int | int32 | int | Edm.Int32 |
bigint | int64 | long | Edm.Int64 |
float | double | double | Edm.Double |
decimal, money | string | bytes + logicalType: decimal | Edm.Decimal |
bool | bool | boolean | Edm.Boolean |
datetime | int64 epoch-millis | long + logicalType: timestamp-millis | Edm.DateTimeOffset |
date | int64 epoch-millis | int + logicalType: date | Edm.Date |
time | string | string | Edm.String |
uuid | string | string + logicalType: uuid | Edm.Guid |
ulid | string | string | Edm.String |
json, file | string | string | Edm.String |
enum, state | generated enum type | generated enum type | namespace-qualified enum type |
array(<inner>) | repeated <inner> | array with items | Collection(<Edm.*>) |
bytes | bytes | bytes | Edm.Binary |
Proto keeps decimal and money as string to preserve precision; Avro encodes them as
a decimal logical type with precision 18 and scale 2 unless the renderer is told
otherwise. Proto emits google.protobuf.Timestamp for date and datetime only when the
well-known-types option is enabled; epoch-millis is the default.
Nullability
Section titled “Nullability”| Format | Nullable field renders as |
|---|---|
| JSON Schema / OpenAPI | type: [X, "null"], or anyOf: [<orig>, {type: "null"}] when the field is a $ref |
| Zod | .nullable() (and .optional() when not required) |
| CUE | (<expr>) | null, with a ? suffix on the key when optional |
| Protocol Buffers | nothing — nullable is not represented. The optional keyword (proto3 field presence) is emitted for non-required non-array fields and for every Patch field |
| Avro | union ["null", <type>] with "default": null |
| OData CSDL | Nullable="true"; "false" is emitted only when the field is required and not nullable |
Per-format detail
Section titled “Per-format detail”OpenAPI 3.1
Section titled “OpenAPI 3.1”The document describes the REST surface. It emits openapi: "3.1.0", an info block
(title from the schema name, version from the schema version, falling back to
0.0.0), a top-level security: [{JWT: []}], and components.securitySchemes.JWT as
HTTP bearer with bearerFormat: JWT.
| Path | Operations |
|---|---|
/rest | post, put, patch, delete — the bulk envelope |
/rest/<entity> | get (list), post (create), put, patch |
/rest/<entity>/id/{id} | get, put, patch, delete |
/rest/<entity>/id/{id}/restore | post — soft-delete reversal |
/x<endpoint path> | one per scripted endpoint declared in the definitions |
<entity> is the literal entity name: the document emits a concrete path set per entity
(/rest/order, /rest/order/id/{id}, …), not one templated {entity} path. Scripted
endpoints keep their declared path under the /x prefix, so path: /reports/sales
emits /x/reports/sales.
List operations declare the query parameters limit, offset, cursor, expression
(jq response shaping) and fields (comma-separated projection). Delete declares
purge. Component schemas are the JSON Schema $defs with $refs rewritten into
#/components/schemas/, plus two synthesised schemas: PageBlock
(has_next_page, end_cursor) and ErrorResponse (error, code, details, with
error and code required).
JSON Schema 2020-12
Section titled “JSON Schema 2020-12”Emits $schema: https://json-schema.org/draft/2020-12/schema, a title (the schema
name, or the entity name on the per-entity route), type: object, $defs, and a
top-level version when the schema declares one. Every entity object sets
additionalProperties: false. Named enums emit {type: string, enum: [...]} under their
canonical PascalCase name; a field carrying an inline enum is referenced as
<Entity><Field>, so status on order becomes OrderStatus.
The per-entity route emits only that entity’s three variants plus the enums its fields actually reference, so file-per-entity codegen does not pull in the whole document.
Field validations fold into schema keywords where an equivalent exists:
| Validation | JSON Schema | Zod |
|---|---|---|
email | format: email | .email() |
url | format: uri | .url() |
ip (param version: v4 / v6) | format: ipv4 / ipv6 | .ip() |
regex (param expression) | pattern | .regex(/…/) |
minmax (params min, max) | minimum / maximum | .min() / .max() |
length (params min, max) | minLength / maxLength | .min() / .max() |
enum, in (param values) | enum | z.enum([...]) |
phone | — | .regex(…) |
creditcard | — | .refine(…) |
Every other validation type is enforced by the engine but does not appear in any export.
CUE expresses the same rules as lattice intersections (int & >=1 & <=1000,
string & =~ "…"); it has no length keyword, so length becomes a ^.{min,max}$ regex.
Emits a package clause derived from the schema name (lowercased, with _, . and -
folded to _), an import "time" when any datetime field exists, #<Enum> disjunctions,
and the three structs per entity. Structs are open by default — they end with ... — so
unknown fields validate; the renderer can close them for strict validation.
Protocol Buffers 3
Section titled “Protocol Buffers 3”Emits syntax = "proto3", a package clause, an optional go_package option, one enum
per schema enum, and three messages per entity. Enums gain a synthesised
<PREFIX>_UNSPECIFIED = 0 and their values sorted alphabetically from 1.
Avro 1.11
Section titled “Avro 1.11”Emits a JSON array of named types — enums first, then three records per entity. Enum
symbols are sanitised to [A-Za-z_][A-Za-z0-9_]*. The record namespace is derived from
the schema name by default and is configurable; because the namespace becomes the
fully-qualified record name, it is also the schema-registry subject prefix, so set it
deliberately rather than accepting the default.
OData V4 CSDL
Section titled “OData V4 CSDL”Emits an edmx:Edmx Version="4.0" document with one Schema element, one
EntityContainer (named Container by default), one EnumType per enum with members
numbered from 0, one EntityType per entity keyed on its primary key, and one
EntitySet per entity. Only the Output variant is emitted.
AsyncAPI 2.6
Section titled “AsyncAPI 2.6”Emits asyncapi: "2.6.0", an info block, an optional servers.default entry, and three
channels per entity — entity.<name>.created, entity.<name>.updated,
entity.<name>.deleted — each with a subscribe operation whose operationId is
on<Entity><Created|Updated|Deleted>. Message payloads carry id, type, source,
subject, time, entity, operation, tenant, user_id and data, with
id, type, time, entity, operation and data required. data refs the entity’s
Output schema; input variants are skipped.
Runtime discovery
Section titled “Runtime discovery”Discovery is how a client learns entity shape at runtime. Every route is GET, requires
a bearer token, and accepts ?datastore=<name>.
| Route | Returns |
|---|---|
/data/schema | Schema summary — name, version, default_datastore, and sorted name lists for datastores, entities, relations, types, enums, traits, embeddables, stateflows, views, functions |
/data/schema/graphql | The generated GraphQL SDL as text/plain |
/data/schema/entities | {entities: [{name, fields, relations}], count}, sorted by name — fields and relations are counts |
/data/schema/entities/{entity} | Full detail for one entity |
/data/stateflows | The declared state machines — name, field, initial_state, states |
/data/stateflow/{name} | One state machine |
/data/admin/data/actions | Catalogue of declared custom actions (admin policy gate) |
/data/admin/data/references/{entity} | Incoming and outgoing references for one entity (admin policy gate) |
/data/admin/data/hashes | The named-query catalogue (admin policy gate) |
The stateflow responses deliberately omit guards, actions, entry/exit hooks and conditions, because those reference scripts a caller should not see.
GET /data/schema/entities/{entity} is the shape call. Fields come back in declaration
order:
{ "name": "order", "table_name": "orders", "primary_key": "id", "materialization": "", "fields": [ { "name": "id", "type": "", "required": true, "nullable": false, "unique": true, "primary_key": true, "computed": false, "default": "ulid()" }, { "name": "risk_band", "type": "credit_score", "required": false, "nullable": true, "unique": false, "primary_key": false, "computed": false, "default": null } ], "relations": [ { "name": "items", "type": 2, "to_entity": "order_item", "from_field": "id", "to_field": "order_id" } ]}curl -H "Authorization: Bearer $TOKEN" https://api.example.com/data/schema/graphqlcurl -H "Authorization: Bearer $TOKEN" https://api.example.com/data/schema/jsonschema/order.jsonMaking a definition change visible
Section titled “Making a definition change visible”Exports and the SDL are rendered from the tenant’s resolved schema, which is cached per tenant and datastore. Loading new definitions does not by itself change what these routes return — the cached engine has to be dropped first.
curl -X DELETE -H "Authorization: Bearer $TOKEN" https://api.example.com/data/cacheThe response is {"cleared": true, "tenant": "<tenant key>"}. Every cached resource for
the calling tenant — schemas, pools, engines, action registries — is dropped and the next
request rebuilds them.
Errors
Section titled “Errors”| Code | HTTP | Meaning |
|---|---|---|
v2.no_tenant | 403 | No tenant key on the request. The gateway did not inject the tenant headers, or the service was called directly without them. |
v2.engine_unavailable | 500 | The tenant’s engine could not be resolved or built — datastore unreachable, or the definitions failed to load. |
v2.missing_entity | 400 | The entity path segment was empty, e.g. a request to /data/schema/jsonschema/. |
v2.entity_not_found | 404 | The named entity is not in the tenant’s resolved schema. Check the spelling and the ?datastore=. |
v2.export_render_failed | 500 | A renderer returned an error while producing the document. |
v2.cache_clear_failed | 500 | Cache invalidation failed while draining the tenant’s scopes. |
The full external code table is on the block’s error reference, and the endpoint-level HTTP reference is at /api/data/.