Validation & Schema Generation
Checking a config without starting anything, its
--jsonreport for programs, emitting the JSON Schema that describes every config, and exporting the design system for an agent.
Three commands that read a config and produce a file or a verdict. All three work on every distribution — the binary, Docker, Homebrew — and none of them starts a server or opens a database.
sovrium validate <file>
Usage: sovrium validate <config>The pre-deploy gate. Prints Valid configuration: <name> and exits 0, or the errors and exits 1.
sovrium validate app.yaml
sovrium validate app.yaml || exit 1One validation, three commands. validate, start and build read your config through the same pipeline: the same authoring shorthands are accepted, and the same cross-field rules are enforced. A config sovrium validate accepts is a config sovrium start boots.
--json — the same verdict, for a program
sovrium validate app.yaml --json reports the verdict as one JSON document instead of prose. It is for the readers a terminal does not serve: an editor underlining the offending line, a CI step, a supervising shell, or the AI that just wrote the config and has to find out whether the edit landed.
Two guarantees make it safe to parse:
- stdout carries the JSON document and nothing else. Anything conversational stays on stderr — the
Using app.yaml (auto-discovered)notice, and theError:line for a file that could not be read at all. Nothing is ever interleaved with the document. - The exit code is unchanged.
0for a valid config,1for an invalid one.--jsonchanges the shape of the report, never the verdict, so a script that already wrapssovrium validatekeeps working when you add the flag.
An invalid config — a text component carrying tag, where the property is spelled element, in a config split across $ref files:
{
"valid": false,
"files": ["/srv/invoices/app.yaml", "/srv/invoices/config/pages.yaml"],
"findings": [
{
"path": "pages[0].components[0]",
"message": "Unknown property 'tag' on component type 'text'",
"accepted": [
"type",
"children",
"props",
"content",
"interactions",
"responsive",
"visibility",
"i18n",
"session",
"element",
"required"
],
"sourceFile": "pages.yaml",
"severity": "error"
}
],
"notices": []
}| Field | Meaning |
|---|---|
valid |
The verdict — the same one the exit code carries |
files |
Every file the verdict covered: the root, plus each $ref partial or imported module. This is how a watcher learns which files to follow |
findings |
One entry per refusal; empty when the config is valid |
notices |
Non-fatal messages. A notice never makes valid false and never changes the exit code, so a deploy gate cannot fail on a working config |
A finding:
| Field | Meaning |
|---|---|
path |
Dotted and indexed path from the config root. Empty for a refusal belonging to the config as a whole rather than to one position |
message |
What is wrong, in one line. It names the position and the expected shape — and the offending value only where that value is a name (see below) |
accepted |
What may be written there instead — read off the schema, and never elided. For an unknown component type that is every legal type, in full. Absent when there are no alternatives |
sourceFile |
The $ref partial the mistake lives in, present only for a split config. There, path names a position in the resolved document, which exists in no file; this names the file to open |
severity |
"error" on every finding. Every refusal validate reports is fatal; the field exists so a reader never has to infer that from the exit code of the whole run |
Two things to build around. A config that cannot be read at all — a missing file, an unsupported extension — is refused before a verdict exists, so it prints an Error: line on stderr and exits 1 with no JSON document. And the decoder stops at the first structural refusal, so a config with several unrecognised properties reports them one run at a time.
The same finding shape is published by a running instance's status file, described in Lifecycle Commands, and pushed to the browser when a --watch save is refused — one vocabulary, whether you asked the question or were told the answer.
A finding carries the shape, not your config's values
The prose report echoes the value it rejected, because you wrote the file and the value is the part you act on. --json does not, and neither does any other machine-readable channel: its output goes wherever the caller sends it — a CI log, an editor's panel, an assistant's transcript — and the mistake that most often reaches a decoder is an env: block written as a mapping rather than a list, where the rejected value is a credential.
A finding names a rejected value only where that value was checked against a closed set of names, which the same finding publishes in full under accepted. So:
Unknown component type 'txt'keeps the value.acceptedlists every legaltype, sotxtis a misspelled name and telling you which one you wrote is the whole diagnosis.Unknown property 'defalt' on field type 'date'keeps it too. That is a key you wrote, not a value — and it is the thing to delete.Expected array | undefineddrops it. There is no list of legal values to check against, so whatever sat there was free-form data.
What survives is always enough to act on: the path, the complaint, and the shape that belongs. Run sovrium validate without --json when you want the value back.
Notices
A notice is something worth telling you that is not worth failing over. The config is valid and ships: valid stays true and the exit code stays 0, so a deploy gate never trips on one. In prose mode notices print to stderr ahead of the verdict, which keeps stdout parseable; under --json they arrive in the notices array. Today two exist — a superseded design key, and a field whose id is left implicit.
field-id-implicit
A field id is optional. Leave it out and the decoder fills it in from the field's position in the list — so the identity exists whether or not you wrote it, and you cannot see it.
$ sovrium validate app.yaml
Notice:
field-id-implicit: table "contacts" — "full_name", "email" declare no id, so the id is the field's position in the list. Inserting a field above one of them shifts every id after it, and the migration diff reads that as a rename. Give each field an explicit id — keep the ones it has today, and give new fields the next unused number.
Valid configuration: crmThe migration engine diffs tables by id, so inserting a field anywhere but the end shifts every id after it. Three fields with no ids decode as 1, 2, 3; add one at the top and they decode as 2, 3, 4 while the newcomer takes 1 — every field now carries the id that used to belong to its neighbour, and the diff reads that as a cascade of renames between fields nobody renamed.
Nobody writes that by hand. An AI asked to "add a field before status" writes it every time, which is why the notice exists now rather than when ids were introduced.
The fix is to write the ids down:
tables:
- name: contacts
fields:
- id: 1
name: full_name
type: single-line-text
- id: 2
name: email
type: emailKeep the id each field has today — its current position, counting from 1 — and give every new field the next unused number, wherever in the list you put it. The id is identity; the array position is still what orders the fields, so the two are free to disagree. That is the whole point: a field inserted at the top with the next unused id changes the order and renames nothing.
One notice per table, not per field: a table written before ids were explicit omits every one of them, and forty identical lines teach their reader to ignore notices. The table is named because "some field somewhere has no id" is not actionable in a config split across a dozen $ref files, and a field with no name is referred to by the position that is its id. The token field-id-implicit is in the message so it is greppable.
Validating from inside a config
To check a config from a running app — a webhook that accepts a submitted config, a scheduled audit of a config in storage — use the sovrium automation action with the validateConfig operator. It runs the same decoder, with no side effects and no boot.
automations:
- name: check-submitted-config
trigger:
type: webhook
method: POST
actions:
- name: check
type: sovrium
operator: validateConfig
props:
config: '{{trigger.data.config}}'
format: autoThe step exposes {{steps.check.valid}} and {{steps.check.errors}}. config takes the config object or a serialized string; format reads the string arm — json (the default), yaml, or auto to try JSON then YAML.
Two behaviours are worth knowing before you build on it:
- An invalid config is a successful step, reported as
{ valid: false, errors }. Validation is a verdict, not a fault, so the run continues and your next step decides what to do about it. - The candidate is read verbatim. A
{{...}}or$env.Xoccurring inside the config you submit is not resolved — it is validated as the literal text it is. That is what makes the verdict trustworthy: what gets checked is exactly what you passed, not a rewritten copy of it.
sovrium schema
Usage: sovrium schema [options]Print the JSON Schema (Draft 2020-12) for the app configuration — the same document the hosted schema URLs serve.
sovrium schema
sovrium schema --output app.schema.jsonIt takes no arguments beyond the output path and reads nothing from the environment: the schema is derived from the config schema itself, so the output depends only on the Sovrium version. That makes it safe to regenerate in CI and diff — a change in the file is a change in the schema, never in the machine that ran it.
A common use is pinning the schema beside the config so editors validate against the exact version you deploy. Regenerate it after every upgrade, and point your config at it:
# yaml-language-server: $schema=./app.schema.json
name: my-appsovrium design-system
Usage: sovrium design-system [config] [options]Export the app's design system — the design block plus everything it inherits — as a brief written for an agent, or as a standard token document.
sovrium design-system app.yaml # the brief, on stdout
sovrium design-system app.yaml --output DESIGN.md # committed beside the config
sovrium design-system app.yaml --format json --output tokens.json # DTCG tokens, for toolingMarkdown is the default because the default reader is a model. --format json emits a W3C Design Tokens (DTCG 2025.10) document, with the Sovrium-specific layer — principles, voice, colour roles, component guidance — carried in $extensions.
Like sovrium schema it runs offline. Unlike it, the output depends on your config, so it belongs in a pre-commit hook or a CI step that regenerates the committed brief when the design changes.
Two things it refuses rather than works around:
- An unknown
--format. Falling back to markdown would let a CI step asking for something else exit0having written the wrong file. - A config that fails validation. Exporting from an unvalidated config produces a design system describing an app that cannot boot — handed to an agent, that is a brief for building against something nobody runs.
The same content is available at runtime from GET /api/admin/design-system.md and GET /api/admin/design-system.json, both admin-gated.
Behaviour
Validate Configuration Files via CLI
- Validates a valid JSON config and exits 0
- Validates a valid YAML config and exits 0
- Validates a valid .yml config and exits 0
- Reports validation error for missing required field and exits 1
- Reports validation error for invalid field value and exits 1
- Reports parse error for malformed JSON and exits 1
- Reports parse error for malformed YAML and exits 1
- Reports file not found error and exits 1
- Reports unsupported file format error and exits 1
- A config declaring a
deleted-byfield validates and exits 0 — the post-decode sweep must not reject a field type the schema's own union accepts - An
onRowClickaction variant the row-click handler ignores is rejected, and the report namesonRowClickplus the union type and the path - Both
onRowClickvariants the handler implements (navigate,openDrawer) validate and exit 0 — the narrowing must not over-reject - Names the unrecognised property and its config path, without the decoder's union-tree noise
- Suggests the accepted property a near-miss was meant to be, and lists the accepted properties for that node
- Attributes an unrecognised property to the
$reffile the partial came from - Accepts an unrecognised key inside
props, and rejects the same key outsideprops - Reports an unrecognised trigger property in the same named, located, readable format
- Rejects a
form.fields[].fieldnaming an undeclared column, reporting the array index and the available fields - Refuses the retired
data-formspelling by name and sends the author toform, suppressing the accepted-values list - Rejects a
kanban.card.footer[].fieldnaming an undeclared column - Rejects a
drawer.recordFields[].namenaming an undeclared column when the drawer is table-bound - Accepts a system-source-bound
drawerwhoserecordFieldsname endpoint-envelope keys rather than table columns - Rejects the aggregate bindings —
chart.series[].field,chart.chartAggregate.field/.groupBy,kpi.kpiAggregate.field - Checks bare-string field arrays too —
form.fieldGroups[].fields[]anddataSource.fields[] - Accepts an endpoint-bound
formwhosefields[].fieldname JSON request-body keys rather than table columns - Resolves system columns in array positions (
id,created_at,createdAt) exactly as in scalar ones - Checks
dataSource.filter[].field/sort[].fieldon every data-bound component, not only ontable - A rejected union-typed property names the accepted variants (
Navigate Action,Open Drawer Action), not only the internal union type name - An unknown component
typeis reported at its path with the legaltypevalues enumerated in full — no truncation, no decoder noise - A
timelinedeclaring bothchildrenanddataSourceis refused at its path naming both keys, and each shape alone is accepted - Rejects a
kpiwhosedataSource.tablenames an undeclared table, naming the component and the available tables - Checks
dataSource.tableon every component spreading the shared schema —chart,kanban,calendar,gallery,list,timeline,container,form,record-field— each named in one run - Rejects the bespoke source schemas shaped after the shared one —
record-pickeranddrawer— whosedescriptionpromises validation today - Checks the page-level surfaces the component walk cannot see:
pages[].dataSource.table,pages[].collection.table,layout.sidebar[].dataSource.table - Rejects a
crudaction'saction.tablenaming an undeclared table — the binding a button carries rather than a component - Accepts every one of those surfaces bound to a DECLARED table, and keeps accepting a
$param.<name>route reference — the fix must not over-reject --jsonon a VALID config writes exactly one JSON document to stdout and nothing else, carryingvalid: trueand an emptyfindings, exit 0- A
--jsonfinding carriespath,message,severity: "error"and the REALacceptedvalues read off the schema; the exit code stays 1 - A
--jsonfinding raised inside a$refpartial names that partial insourceFile, andfileslists every file of the config graph - A superseded-key notice reaches
noticeswithvalid: trueand exit 0, and never leaks onto stdout as prose - A table whose fields omit
idvalidates (exit 0) and earns afield-id-implicitnotice on stderr naming the table and a field without one - The same table with an explicit
idon every field earns no such notice — it tracks the omission, not the presence of tables (control, green today) - A
--jsonfinding names the EXPECTED type and the offending key but not the rejected value, while the prose report still echoes it - A rejected union value is not echoed on the
--jsonchannel either, whileExpected RowClickActionand both accepted variants survive - A rejected component TYPE is still named, alongside the closed set of accepted values it was checked against (over-stripping control, green today)
- Complete config validation workflow passes regression test
Export the Design System Beside the Config
- Prints an llms.txt-shaped markdown brief to stdout by default
--format jsonprints a parseable DTCG document with the object colour value--outputwrites the file, creating parent directories, and does not also print it- Runs offline against the real
apps/partner/app.tsand names itssignatureaccent - Should refuse an unknown
--formatrather than silently defaulting - Should refuse a config that fails validation instead of exporting a partial system
- A developer can export the design system beside their config, in either format (regression)
Configuration Sources
- Server starts with valid YAML config file
- Supports .yml file extension
- Invalid YAML syntax returns clear error message
- Schema validation errors are reported
- YAML-specific features work (comments, anchors)
- Comprehensive YAML config with all features works
- File not found error is reported for YAML config
- Server starts with valid JSON config file
- Invalid JSON syntax returns clear error message
- Schema validation errors are reported
- File not found error is reported with helpful message
- JSON config with all app schema features works
- Environment variable overrides work with JSON
- Auto-detects JSON format from .json extension
- Auto-detects YAML format from .yaml extension
- Auto-detects YAML format from .yml extension
- Reports error for unsupported file extensions
- Handles mixed case extensions correctly
Last updated September 24, 2026
This documentation was written with AI, so errors or outdated content are possible. Sovrium is in beta. Contributions and corrections are welcome.