JSON Schema¶
A schema is a named picture of a payload: the shape a read is expected to return, written down once so a pipeline can be refused the moment the payload moves out from under it. Dirigent holds schemas as a first-class resource -- create, list, show, update, delete -- and validates every one as a JSON Schema, Draft 2020-12 when you store it. This page is that language, taught on the shapes a pipeline actually reads.
A schema is locally authored. It is the picture you hold of the payload, not something fetched or introspected from the source. That is the whole point: the source cannot tell you it changed, so you say what you expect, and dirigent tells you when the two stop matching.
A schema is applied on its own¶
A pipeline document carries a pipeline. A schema is not part of one -- it is applied by itself, the way a connection is:
dg schema create schemas/acme-sites.json # from a file
dg schema create - # from stdin
dg schema list
dg schema show acme-sites
dg schema update acme-sites schemas/acme-sites.json # the corrected file, labels and all
dg schema delete acme-sites
A schema reads its own identity from its keywords, so there is nothing else to pass:
| Keyword | Becomes | If absent |
|---|---|---|
$id |
the code the schema is addressed by |
the file's stem (acme-sites.json -> acme-sites) |
title |
the name shown beside the code |
the schema has no name, and the code is its title |
description |
the body | the schema has no body |
code is the only piece with identity: it is constrained (lowercase, digits, hyphens), unique on
the instance, and what every reference uses. title and description are free text. A $id that
is a full URI is accepted, and its last path segment is taken as the code.
Changing one that is already stored¶
A shape you got wrong is corrected in place. PATCH /api/v1/schemas/{code} changes the name, the
description and the body -- never the code, which is what every reference names -- and the
Schemas screen is that request with the schema in the editor it was written in. The name and the
description are stored beside the body rather than read back out of it, so editing title in the
body does not move the name; the box does.
dg schema update is the same request from a shell, and it changes a shape, its labels, or both:
# the corrected file replaces the shape, and its own title and description come with it
dg schema update acme-sites schemas/acme-sites.json
dg schema update acme-sites - # or from stdin
# labels alone, leaving the stored shape untouched
dg schema update acme-sites --name "Acme sites"
dg schema update acme-sites --description "" # given empty clears what is stored
What this was not given, it leaves alone. A label named is set, a label given empty is
cleared, and one left out stays as it stands -- so a corrected shape sent on its own keeps the
name it was stored with. Where a file replaces the body, that file's own title and
description come with it, exactly as dg schema create reads them, because the labels should
belong to the shape the instance now holds rather than to the one it no longer does. Pass
--name or --description to override what the file declares. This is the one place dg and
the Schemas screen part company on purpose: the screen edits a shape in the boxes beside it, so
there title in the body moves nothing, while the command line hands over a whole authored file
and its keywords travel with it. The Apply dialog does the same when it replaces a carried
schema, for the same reason.
A run holds the shapes it started with. When a run is created it reads the body of every
schema its document names -- each code under requires.schemas, and each one a step's config
names -- and keeps those bodies on the run itself, the way it keeps its resolved parameters. Every
attempt of that run, every later step of it, and every retry of any of its steps check against
those bodies and not against the table. So an edit reaches the next run of a pipeline naming the
code, and never a run in flight.
That is the same grain the rest of a run works at: the pipeline version, the parameters, the priority and the worker tags are all resolved once when the run is created and read from the run from then on. A schema is one more of them.
The bodies are held, not the codes, because there is no schema versioning here for a code to resolve back through: a run that kept only the code would be back to reading whatever the table now holds. Only the codes the document names are read, so a run carries the shapes it checks against rather than a copy of the instance's whole shelf.
A ${...} in a step's schema is refused when the document is applied, with
document.step_schema_interpolated. A code that is not a code until the run has started is not
one the run could have pinned, and it is not one anything else can name either -- neither the
used_by that names a schema's dependants nor the requires preflight can see it. A gate names
its shape by writing the code.
If a run is created while a schema its document names is not on the instance -- a state no apply
can produce, since applying such a document is refused and removing such a schema is too -- the
run is refused with run.schema_not_held rather than started to fail at its gate.
So an edit is reported and a removal is refused. Every schema response carries used_by: the
codes of the stored pipelines whose current version names the schema, whether under
requires.schemas or in a step's schema field. The edit itself still lands, because correcting a
shape every pipeline should now check against is what editing a stored schema is for, the
code cannot be changed to sidestep it, and whether a new body still admits what a dependent
pipeline sends is not a question a schema can be asked. The Schemas screen names those pipelines
beside the body instead, so the decision is made with them in view rather than blind, and the
schema.updated record dg schema update writes carries the same list for the same reason. If
the new shape is not what the other readers of that code expect, store it under a code of its own.
DELETE /api/v1/schemas/{code} is the half that is refused, with schema.in_use, for as long as
used_by is not empty. Applying a document that names a schema the instance does not hold is
already refused by the requires preflight, so a delete that went through would leave behind
exactly the state no apply is allowed to create -- and the next apply of an untouched pipeline
would be what discovered it. Only each pipeline's current version counts: a version nobody
applies any more is history, and a pipeline stops naming a code by applying a version that does
not, after which the code is free to remove.
The shape of a shape¶
Every example below describes the same read, GET /api/sites.json?fields=id,name,elevation,
whose payload is an object wrapping a list:
{"sites": [
{"id": "NO-BRGN-01", "name": "Bergen Florida", "elevation": 12},
{"id": "NO-TRMS-02", "name": "Tromso Holt", "elevation": 21}
]}
The smallest schema that says anything is a type:
That accepts the payload above and rejects a list or a string. type is the first question a
schema asks of a value, and the seven answers are object, array, string, number,
integer, boolean, and null.
Objects: properties, required, and the closed door¶
properties names the fields of an object and gives each its own schema. required lists the
ones that must be present:
A field named in properties is described but not demanded -- absence is fine, a wrong type is
not. required is the separate question of presence. A field that is present but not named in
properties is, by default, allowed and unchecked: JSON Schema is open by default, so extra
keys pass silently.
Say additionalProperties: false to close that door -- to reject any field you did not name:
{
"type": "object",
"required": ["sites"],
"properties": {"sites": {"type": "array"}},
"additionalProperties": false
}
Now {"sites": [], "pager": {}} is refused, because pager was not named. This is the
difference between "the fields I need are here" and "these are the only fields there are." A read
that pins a payload usually wants the closed door on the objects it fully understands, and the open
door where the source may add fields you do not care about.
Arrays and the shape of an element¶
items gives one schema that every element of an array must satisfy. Nest it into the field to
describe the list's contents:
{
"type": "object",
"required": ["sites"],
"properties": {
"sites": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "name", "elevation"],
"properties": {
"id": {"type": "string"},
"name": {"type": "string"},
"elevation": {"type": "integer", "minimum": -500}
}
}
}
}
}
This is the whole acme-sites example. It accepts the payload at the top of the page and
rejects each of these, each for one reason:
{"sites": [{"id": "x", "name": "Bergen Florida"}]}-- the element is missingelevation.{"sites": [{"id": "x", "name": "Bergen Florida", "elevation": -900}]}--elevationis belowminimum.{"sites": [{"id": 42, "name": "Bergen Florida", "elevation": 12}]}--idis a number, not a string.
minItems and maxItems bound the length; uniqueItems: true forbids duplicate elements, with
1 and 1.0 equal, true and 1 different, and an object's keys in no order.
Numbers and strings: pinning the value, not just the type¶
A string takes minLength, maxLength, and pattern -- a regular expression the whole string is
tested against. An Acme site id is a two-letter country code, a four-letter site code, and a
two-digit index:
A pattern is read with Python's regular expression syntax and meaning, which JSON Schema's
ECMA-262 patterns mostly share: $ also matches before a final newline, and \d and \w match
Unicode digits and letters, so anchor with \Z or spell a class such as [0-9] where that
matters. A pattern Python cannot compile is refused when the schema is stored or applied.
A pattern that backtracks, such as the nested repeat ^(a+)+$, can take time exponential in the
string it reads. The engine matches without holding the worker, and a check stops at its bound:
a validate.schema step at its timeout (10 seconds by default), failing as rejected with
base.value_unchecked, and a run's parameters after one second, refusing the run with
parameter.unchecked. A schema still fails that way on every value long enough, so write
patterns that do not backtrack.
That accepts "NO-BRGN-01" and rejects "too-short" and "has a space here!!". A number or
integer takes minimum, maximum, and the exclusive pair exclusiveMinimum / exclusiveMaximum.
enum: the value is one of a known set¶
enum lists the values a field may take. The Acme service's measure is a fixed vocabulary,
and a payload carrying a word that is not in it is exactly the drift a schema exists to catch:
{
"type": "string",
"enum": ["TEMPERATURE", "PRECIPITATION", "WIND_SPEED", "WIND_DIRECTION", "HUMIDITY", "PRESSURE"]
}
"TEMPERATURE" passes; "WINDCHILL" is refused. enum works on any type -- "enum": [1, 2, 3]
or "enum": [true, false] -- and const is the one-value case, "const": "HOURLY".
Reuse: $defs and $ref¶
When a shape appears twice, name it once under $defs and point at it with $ref. A site id is
the same three parts wherever it appears, so it is written once:
{
"type": "object",
"required": ["sites"],
"properties": {
"sites": {
"type": "array",
"items": {
"type": "object",
"required": ["id"],
"properties": {"id": {"$ref": "#/$defs/site_id"}}
}
}
},
"$defs": {
"site_id": {"type": "string", "pattern": "^[A-Z]{2}-[A-Z]{4}-[0-9]{2}$"}
}
}
#/$defs/site_id is a JSON Pointer into this document: # is the root, and the path walks to
the named subschema. A $ref may point anywhere in the document, and a $def may $ref another,
so a shape composes out of named parts.
Reuse across schemas: a $ref to another stored schema¶
A schema the instance holds can point at another one it holds, by code:
station-code is the code of a stored schema, and station-code#/$defs/x points inside it. The
reference is resolved against the referring schema's $id first, the way JSON Schema resolves
every $ref, and then reaches a stored schema in one of two ways:
- A bare code --
station-code, orstation-code.json-- reaches the schema stored under that code. This is what a reference in a schema whose$idis itself a code (or absent) gives. - An address --
https://example.org/schemas/station-code.json, or a relative$refinside a schema whose$idis such an address -- reaches a stored schema only when that schema's own$idis exactly that address. A link to some other schema whose address happens to end in a stored code reaches nothing, so it is refused rather than checked against a local shape it never meant.
A run takes every schema its document names and every one they reach this way when it starts,
so editing either changes the next run and never one in flight. Plan refuses a reference that
reaches no stored schema (document.schema_reference_unknown, at the place the document names
the schema), a schema another one references cannot be deleted (schema.referenced), and a
pipeline that checks against a schema through another's $ref is listed among the pipelines
using it. The Schemas screen's tester and dg schema check follow the same references.
Nothing is ever fetched. A $ref to a URL that reaches no stored schema is not retrieved,
because a schema is validated on the server and the workers and a fetch would be a request from
their network to an address the document chose. One to a JSON Schema draft's own metaschema
resolves, because those ship with the validator. Anything else fails where it is used: a
pipeline's params refuses the run, and validate.schema fails the step as rejected.
format: the string that is really a date, or a site id¶
format annotates a string with the kind of string it is -- a date, a UUID, an email. In JSON
Schema, format is an annotation by default and asserts nothing; a validator only checks it when it
is handed a format checker. Dirigent's engine always hands its validator a checker, so a
format you write is enforced. Alongside the standard formats (date-time, date, time,
email, uri, ipv4, ipv6, hostname, regex, and the rest), dirigent registers the ones its
own data speaks:
| Format | A string that is | Example |
|---|---|---|
ulid |
a Crockford base-32 ULID, 26 chars | 01ARZ3NDEKTSV4RRFFQ69G5FAV |
uuid4 |
a random UUID (version 4) | f47ac10b-58cc-4372-a567-0e02b2c3d479 |
uuid7 |
a time-ordered UUID (version 7) | 018f6d8e-1a2b-7c3d-8e4f-0123456789ab |
md5 |
a 32-character hex digest | d41d8cd98f00b204e9800998ecf8427e |
sha1 |
a 40-character hex digest | da39a3ee5e6b4b0d3255bfef95601890afd80709 |
sha256 |
a 64-character hex digest | e3b0c442...b855 (64 hex characters) |
sha512 |
a 128-character hex digest | cf83e135...3e27 (128 hex characters) |
base64 |
standard base-64, decodable | aGVsbG8= |
humane-duration |
a duration in dirigent's own spelling, never ISO 8601 | 30s, 1h30m |
So a record's observed_at is pinned as a real timestamp, not just any string:
"2026-01-01T09:30:00Z" passes; "last Tuesday" is refused. A format only ever narrows a
string: a value of the wrong type is caught by type, and format speaks only once the value is
already a string.
A pack adds its own formats the same way it adds blocks: a Contribution carries a formats
map of name to checker, and every format an installed pack contributes joins the ones above in
the checker the engine hands its validator. So a dirigent-acme pack contributes
acme-site-id and acme-period, and a schema that writes format: acme-site-id
asserts wherever that pack is installed. This keeps a schema portable: a format no installed
pack contributes stays a passing annotation -- the value is valid, just unchecked -- so the same
schema asserts on an instance that has the pack and passes on one that does not. A pack that
does this for a real system is
dirigent-dhis2.
Where a schema is enforced¶
Storing a schema validates the schema -- that it is itself a legal Draft 2020-12 document. A
schema validates a payload at a validate.schema gate, which passes a value through when it
fits and fails the run at the boundary when it does not. A gate never writes a shape inline: its
schema is a code, and the code resolves one of two ways.
The code names a schema the instance holds, applied on its own the way a pipeline's connection is created rather than carried:
check:
block: validate.schema
depends_on: [fetch]
config:
input: ${steps.fetch.output.body}
schema: acme-sites
Because a named schema is a resource with a code, a document that references one declares it,
and applying that document on an instance that does not hold the schema is refused by the same
requires preflight that guards blocks and connections:
Or the document carries the shape itself, in a top-level schemas: section keyed by code -- the
same section, and the same rules, as the top-level connections: a document can carry:
schemas:
site-record:
type: object
required: [sites]
properties:
sites: { type: array }
steps:
check:
block: validate.schema
depends_on: [fetch]
config:
input: ${steps.fetch.output.body}
schema: site-record
A carried schema resolves the gate's code before any instance-held one, so a document that
carries every shape it names runs on its own; it needs no requires.schemas entry, because it
satisfies its own reference. Its body is checked to be a valid schema when the document is
applied, the way the parameter schema is. A server refuses a document that carries a schema,
exactly as it refuses one that carries a connection -- storing it would put the shape in every
version of the pipeline -- so the carried form is for a standalone or --local run, and a shared
instance holds its schemas as their own resource. A document applied into a local run with
--also-apply keeps the schemas it carries too, so a child started through pipeline.run
resolves its own gates. A dg run --local run holds no instance schema until one is handed
to it:
A pipeline's own params schema is the other place a payload is validated: the parameters of
every run, whoever asked for it -- the API, the CLI, a schedule's pins, a backfill, a parent's
pipeline.run -- are checked against it before the run is created, and against the same
assembled checker a gate uses. So a parameter declared format: date refuses 2026-13-40 at
the door rather than carrying it into the first step, and a parameter declared
format: acme-site-id asserts on an instance where that pack is installed and passes on
one where it is not.
The reference shelf¶
Draft 2020-12 is the full language; the working set, by what a keyword is doing:
| Doing | Keywords |
|---|---|
| Naming the kind | type (object, array, string, number, integer, boolean, null) |
| Objects | properties, required, additionalProperties, patternProperties, propertyNames, minProperties, maxProperties |
| Arrays | items, prefixItems, contains, minItems, maxItems, uniqueItems |
| Numbers | minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf |
| Strings | minLength, maxLength, pattern, format |
| A fixed value | enum, const |
| Combining | allOf, anyOf, oneOf, not, if/then/else |
| Reuse | $defs, $ref (a JSON Pointer, #/$defs/name, or a stored schema's code or exact $id) |
| Identity | $id (-> code), title (-> name), description (-> body), $schema, $comment |
examples/schemas/ and
examples/validate/ are
this language exercised end to end: a schema applied with dg schema create and no pipeline in
sight, a document that carries the same shape itself, and a payload refused at the gate.