Schema resolution reference
For each file, manni meta resolves a schema set of one or more schema references. It then validates the file’s metadata against every schema in that set. This page documents how the set is chosen, what a reference can be, and which JSON Schema dialects are supported.
Precedence
Section titled “Precedence”manni meta determines the schema set for a file by checking these sources in order and using the first one that yields a non-empty set:
| Level | Source | Scope |
|---|---|---|
| 1 | -s/--schema on the command line |
All files in the run. |
| 2 | $schema in the file’s own metadata |
That file only. A repo can narrow what a document may name. |
| 3 | First matching config overrides entry (by glob) |
Files matching the glob. An entry whose files is a list matches on any glob in it, and still counts as one entry here. |
| 4 | Config schemas |
All files not matched above. |
| 5 | Built-in default set: google:okf:0.1 + passo-uno:seven-action:1.0 |
Every file, when nothing else applies. |
Once a level produces a set, lower levels are not consulted for that file. Within a resolved set, duplicate references are removed.
The $schema key in a file
Section titled “The $schema key in a file”A file can name its own schema with a $schema key in its metadata. The value
may be a single reference or a list of references:
---$schema: ./schemas/guide.schema.jsontype: guide------$schema: - google:okf:0.1 - ./schemas/extra.schema.jsontype: concept---$schema is a manni meta directive, not part of the document’s metadata. It is
reserved and stripped before validation, so a schema with
additionalProperties: false will not flag it. A $schema value that is
neither a string nor a list of strings is an error for that file.
What a document may reference, and how a repo narrows it
Section titled “What a document may reference, and how a repo narrows it”By default a document’s $schema may name any of the three
kinds: a built-in id, a local file, or a URL. That is the
feature: a page carries its own contract, and level 2 of the chain is above
config precisely so it can.
It also means one line of frontmatter chooses the schema that file is judged by. In a repository whose pull requests come from outside, that is a way to opt out of the standard. A document naming a permissive URL passes, while the documents playing by the config’s rules fail.
schemaTrust is how a repo
narrows level 2. It changes nothing else in the chain:
schemaTrust.documentRefs |
Effect on level 2 |
|---|---|
any (default) |
Unchanged. All three kinds, from any host unless schemaTrust.hosts is set. |
local |
Built-in ids and repository files resolve, and a URL is refused. The exception is a published built-in URL, which is a built-in under a second name. |
none |
Level 2 is skipped entirely, so resolution continues at level 3. The dropped reference is reported on stderr. |
Two things hold in every mode, including the default:
- A document-supplied path must resolve inside the repository, meaning the
git root, so a monorepo’s
../shared/house.jsonstill resolves. A path that escapes it is refused. - A reference an operator supplied is never filtered.
schemas,overrides[].schemas, and-s/--schemaresolve exactly as they always have, in every mode, including to a path outside the repository.
A refused reference fails that file (exit 1) with the reason as its error. The rest of the run is validated normally, and the annotation lands on the document that carried the reference.
Reference kinds
Section titled “Reference kinds”A schema reference, wherever it comes from (CLI, $schema, or config), is
classified into one of three kinds by its shape:
| Kind | Looks like | Resolved from |
|---|---|---|
| builtin | vendor:name:version, e.g. google:okf:0.1 |
A schema shipped with manni meta. |
| file | A .json path or any path containing a separator, e.g. ./my.schema.json |
The local filesystem. |
| url | An http:// or https:// URL |
Fetched over the network. The exception is a published built-in URL, which manni meta answers from its own bundle. |
How a reference is classified:
- A reference starting with
http://orhttps://is a url. - Otherwise, a reference is a builtin when it contains no path separator
(
/or\) and does not end in.json. It must also match the built-in id pattern, which is colon-separated segments of[a-z0-9._-], such asgoogle:okf:0.1. - Everything else is a file.
This deliberately classifies a Windows path (C:\...), a URL, and any .json
reference as non-builtin. A typo’d built-in id is then reported as an unknown
built-in rather than silently treated as a missing file.
File references
Section titled “File references”A relative file reference resolves against the directory of whatever named it. That is the directory the person who wrote it was looking at:
| Where the ref came from | Resolved relative to |
|---|---|
schemas: or overrides[].schemas in a config |
The config file’s directory |
A document’s own $schema |
The current working directory |
-s/--schema on the command line |
The current working directory |
So schemas: ["./house.schema.json"] in a config at the repo root means the
file next to that config. That holds whether you run manni meta from the repo root,
from docs/, or with -c ../manni.config.yaml. See config
discovery.
A file reference a document supplied has one extra rule: it must resolve
inside the repository. $schema: ../../../../etc/passwd is refused rather than
read, in every schemaTrust mode. A file reference from a config or from
--schema has no such limit. See what a document may
reference.
If a local schema file is not valid JSON, manni meta reports the reference and the
line and column where parsing failed. It does not quote the file’s contents
back, because that message travels into json and sarif reports that are
routinely uploaded to code scanning. A fetched body is quoted, deliberately.
Seeing what a URL actually returned is how you tell a gateway from a schema.
Vendored references
Section titled “Vendored references”manni meta schemas vendor turns a URL
reference into a file reference that keeps a record of where it came from:
meta: schemas: - ref: ./schema/2.1.json source: https://schemas.example.com/house/2.1.json integrity: sha256-9f8e7d6c…A pinned reference resolves exactly like any other file reference. ref is
the whole of what resolution sees. It is classified, rebased, deduplicated, and
reported identically to the bare string ./schema/2.1.json. It is also the
string that appears in reports, in json output, and in baseline
fingerprints. source and integrity are
carried alongside and consulted only when the file is read.
Reading it adds one step. Before the JSON is parsed, the file’s bytes are
hashed and compared against integrity. A match resolves normally; a mismatch
is an operational error (exit 2) that stops the run:
manni: Schema "./schema/2.1.json" does not match its recorded integrity. expected sha256-c12b1cfe…7341 found sha256-9091a816…46cbThe file's contents have changed since it was vendored. Re-download it with`manni meta schemas vendor https://schemas.example.com/house/2.1.json`, or updatethe recorded integrity if the change was intended.There is deliberately no fallback. Loading the file anyway, or re-fetching
source instead, would mean the run continues against a contract nobody
approved. That is the failure the pin exists to make visible.
Three other messages come out of the same check, and which one you get says what actually happened:
| Situation | What manni meta reports |
|---|---|
| The bytes differ only in line endings | The contents differ only in line endings…, with a .gitattributes suggestion. A committed schema plus core.autocrlf produces a mismatch on a file nobody edited, and this is the case that would otherwise read as corruption. |
| The file is missing | Schema file not found: "…". It was vendored from <source>; commit the file, or re-download it…. Usually a copy that was never committed. |
| The pin is on a built-in or URL ref | Rejected when the config is parsed, because neither can be verified, so a pin there would be decorative. |
The hash is taken over the raw bytes, and schemas vendor writes exactly what
the server sent. So the pin also attests to the upstream file, and
re-downloading the URL and hashing it reproduces the same value.
URL references
Section titled “URL references”A url reference is fetched with a default 10-second timeout. Within a
single run, fetched schemas are cached per URL, so the same URL is only
retrieved once even when many files reference it. That holds when many files are
processed concurrently, which shares the one in-flight request rather than
opening one per file. A timeout, a non-2xx HTTP status, or a response that is
not valid JSON is an error (exit 2).
A failed request is retried once, after roughly 500 ms, and only for the
failures that can plausibly heal. Those are a network-level error (DNS, refused
connection, reset socket) or a 5xx. A 4xx is not retried, because a 404 will
not become a 200. Neither is a timeout, because the timeout is already the
budget for a host that is not answering. Retrying it would double the ceiling
per URL for no new information.
The cross-run cache
Section titled “The cross-run cache”A schema fetched successfully is written to a disk cache and served from there on later runs. An edit-validate loop against a remote contract does not re-fetch it every time.
| Property | Detail |
|---|---|
| Location | .manni/meta/schema-cache/, under the config file’s directory when a config governs the run, otherwise the working directory. |
| One file per URL | Named for a SHA-256 digest of the URL, holding the schema plus the URL and the fetch time. |
| Freshness | The file’s mtime, against schemaCache.ttlHours (default 24). |
| Turning it off | schemaCache.ttlHours: 0. |
Running from a subdirectory shares one cache with running from the repo root, because the location follows the config rather than where you are standing.
What invalidates an entry:
- Age. Past
ttlHourssince the file was last written, the next run re-fetches and rewrites it. Freshness reads the filesystem’s mtime rather than thefetchedAtrecorded inside the file. A restored cache or a clock change makes an embedded timestamp disagree with reality. An entry that never looks old is a cache that never expires. An mtime in the future is treated as stale for the same reason. It would otherwise satisfy the age check until the clock caught up. An archived cache carries one with it, and so does a machine whose clock ran fast. - A different URL. The key is the full URL, so changing the version segment is a different entry, not a stale one.
- Damage. An unreadable, unparseable, or unrecognized entry is treated as a miss, not an error: a corrupt cache costs one fetch, never a failed run.
A failed fetch is never cached, and a payload the checks below reject is never cached either.
Offline
Section titled “Offline”--offline (or offline: true)
makes a run touch the network not at all:
- A
urlreference is served from the cache, ignoring the TTL. Expiry exists to trigger a re-fetch, and there is none available, so a stale copy beats failing the run. - A
urlreference that is not cached is an operational error (exit 2) naming the URL. - Built-in ids and local
.jsonfiles resolve exactly as they always do.
Priming the cache is one ordinary run. Fetch once online, and every later run
can be --offline. With schemaCache.ttlHours: 0 there is no cache to read, so
--offline fails on any url reference.
What manni meta requires of a fetched schema
Section titled “What manni meta requires of a fetched schema”A URL is a live third party, and 200 OK is not proof that what came back is a
schema. Two checks apply to a fetched payload, and to it only. A local file or a
built-in is something you chose deliberately and can read:
| Requirement | Why |
|---|---|
The body must be a JSON object carrying at least one standard JSON Schema keyword. Any of the validation or applicator vocabularies from draft-04 through 2020-12 counts. That includes $ref, $defs, type, properties, required, allOf/anyOf/oneOf/not, if/then/else, additionalProperties, patternProperties, propertyNames, items/prefixItems/contains, enum, const, format, and the numeric, string and array constraints. |
An object with none of them constrains nothing, so every document passes it. |
| The body must be at most 5 MB. | res.json() would otherwise read an unbounded body into memory. The timeout catches a slow response but not a fast, enormous one. |
The first check exists because of a specific, silent failure. An API gateway, a
proxy, or a misconfigured bucket commonly answers a bad path with 200 OK and a
JSON error envelope:
{ "error": "not found", "requestId": "abc123" }That compiles as a perfectly valid schema with no constraints. Without the check, every document in the repo validates against it and the run is green. It is a gate that reports success because it has stopped working. With it, the run stops with an exit-2 error naming the URL and quoting what the server actually sent.
This is intentionally not meta-schema validation. Four dialects
are compiled, and how strict a schema is remains its author’s business. A sparse
but genuine schema such as {"type": "object"} is accepted. The check targets
one failure, which is a non-schema served as one.
The keyword list is deliberately generous, because the two ways this check can
be wrong are not equally costly. Letting an unusual payload through means it
either fails to compile or behaves as the permissive schema it literally is.
Rejecting a real schema stops a working setup outright. So a schema whose only
root keyword is if/then, patternProperties, or additionalProperties is
accepted, because each is perfectly ordinary. An error envelope carries none of
them.
The size cap counts the bytes actually received. content-length is advisory.
It is absent on a chunked response and can simply be wrong, so it is never
trusted on its own.
Dialects
Section titled “Dialects”Every schema declares its dialect through its own $schema meta-schema URI. The
dialect is auto-detected from that URI, and the schema is compiled with the
matching validator. Schemas of different dialects can coexist in one run.
| Dialect | Detected from meta-schema URI containing | Notes |
|---|---|---|
| Draft 2020-12 | 2020-12 (or anything unrecognized) |
The dialect of the built-in schemas, and the fallback. |
| Draft 2019-09 | 2019-09 |
|
| Draft-07 | draft-07 / draft/7 |
|
| Draft-06 | draft-06 / draft/6 |
Shares the draft-07 validator. |
| Draft-04 | draft-04 / draft/4 |
A schema with a missing or unrecognized $schema meta-schema URI falls back to
Draft 2020-12. Compilation is non-strict, so lax metadata still compiles. A
schema that fails to compile is an error (exit 2).
Validating against the whole set
Section titled “Validating against the whole set”When a file’s resolved set contains more than one schema, the metadata is validated against every schema in the set. The report is the union of all violations. A file passes only when it satisfies all schemas in its set. Each reported error is tagged with the schema that produced it.
Encrypted values
Section titled “Encrypted values”x-manni-encrypt: true on a property says the page must hold that property’s
value encrypted. false, or no keyword at all, is an ordinary property.
{ "properties": { "owner": { "type": "string", "enum": ["platform", "billing"], "x-manni-encrypt": true }, "internal-ticket": { "type": "string", "pattern": "^PROJ-[0-9]+$", "x-manni-encrypt": true } }, "required": ["owner"]}A page then carries a ciphertext, ~ and at least 82 base64url characters:
owner: ~AQx7Vb2…- Where a mark counts. Wherever the validator evaluates it. That includes a
$ref, anallOf, a referenced built-in, and theanyOf,oneOfandif/thenbranches the validator takes. A mark behind a failingifdoes not count, and neither does a mark on a property the page does not have. - Whole values. The mark applies to the whole value. An array or an object is encrypted as one ciphertext. What is encrypted is the value’s JSON, so an encrypted number, boolean or array decrypts to its real type and is validated as that.
- Writers encrypt top-level properties.
manni meta filland amanni meta queryUPDATEorINSERTwrite a marked top-level property encrypted. A nested mark is validated and never written. - A value other than
trueorfalseis a schema error when the schema compiles, exit 2:
./schemas/page.json: "x-manni-encrypt" must be true or false.What manni meta validate does with a marked property:
| Situation | Outcome | Exit |
|---|---|---|
| The page holds a plain value, whatever the key state | Finding encrypted:plain/encrypted: /owner holds a plain value; its schema marks it x-manni-encrypt. |
1 |
| A plain value an external-metadata manifest supplied | Validated as plain text and never flagged. The manifest is private by construction. | 0 or 1 |
| An encrypted value a manifest supplied | The three rows below, unchanged. The exemption above is about plain values alone. A finding names the manifest and the entry’s own line. | 0 or 1 |
| Encrypted, the key is available, and it decrypts | The decrypted value is validated against the property’s full schema: type, enum, pattern, format and the rest. No finding prints the value. |
0 or 1 |
| Encrypted, the key is available, and it does not decrypt | Finding encrypted:unreadable/encrypted: /owner does not decrypt under the current key: encrypted under another key, or edited by hand. |
1 |
| Encrypted, and no key is available | Findings at or under that pointer are dropped, and the run warns once on stderr. | unaffected |
The key is the family’s
encryptionKey:, or
MANNI_ENCRYPTION_KEY. With neither, the run says how many values it could not
check:
manni: 3 encrypted values were not verified: no encryption key is available. Set MANNI_ENCRYPTION_KEY, or run `manni key set`.Two consequences of that last row:
- A condition elsewhere in the schema, such as
if: {properties: {owner: {const: …}}}, still sees the ciphertext. - A baseline written with no key does not record the dropped findings. They return as new on the next run that has the key.
Validation reads a copy of the metadata with every readable value decrypted. The page itself is never changed. A finding inside an encrypted value is filed at the value’s own pointer, so it never names a key of an encrypted object.
Field location
Section titled “Field location”x-manni-location on a property says where the property’s value is kept.
"page" keeps it in the document’s own metadata: frontmatter, HTML <meta>,
a DITA <prolog>. That is the value meant to reach delivered output, and the
agents that fetch it. "external" keeps it in the collection’s
external-metadata manifest.
No keyword is no preference, and every command behaves as it did before the
keyword existed.
{ "properties": { "title": { "type": "string", "x-manni-location": "page" }, "owner": { "type": "string", "x-manni-location": "external" } }}- Where a mark counts. Wherever the validator evaluates it, exactly as for
x-manni-encrypt. That includes a$ref, anallOf, a referenced built-in, and theanyOf,oneOfandif/thenbranches the validator takes. A mark behind a failingifdoes not count, and neither does a mark on a property the document does not have. - Top-level properties only. A manifest owns top-level keys, so only a
top-level property’s mark has an effect. A nested mark, such as one under
review.notes, is accepted and ignored. - The later schema wins. When two schemas in a file’s resolved set mark one key differently, the one later in the set decides. A house schema listed after a vocabulary refines it. Listed before, the vocabulary wins.
- A field join’s own field stays in the page. Under
join: id, the page’sidis the only thing that says which manifest entry is its. So that one field is never treated as external, whatever its mark. A path join, the default, has no such field. - Encrypted and external. A property marked both keeps its ciphertext in the manifest.
- Three schema errors, each at compile time, exit 2:
steward.schema.json: "x-manni-location" must be "page" or "external".steward.schema.json: "$schema" cannot be stored in external metadata.steward.schema.json: "x-manni-location" says both "page" and "external" for "owner".The second is a mark of external on $schema, which a manifest may not
hold. The third is one schema marking a key both ways on paths every document
takes. Those paths are the schema’s own properties, its allOf entries, and
a local $ref reached from them. A remote $ref is not followed.
Marks that differ across anyOf, oneOf, if/then/else or not
branches are not an error. When the validator evaluates both, that schema has
no preference for the key. An earlier schema’s mark still applies.
What manni meta validate does with a marked property. Both findings are
warnings, so neither moves the exit code:
| Situation | Outcome |
|---|---|
The page holds a key its schema prefers external |
Finding location:external/location at the page line: "owner" is stored in the page; steward.schema.json prefers external metadata. Run manni meta relocate. |
| The same, and the page is in none of several collections | The same finding, ending …prefers external metadata, and this document is in none of the 2 collections. |
The same, under --no-config |
The same finding, ending …prefers external metadata, and --no-config leaves it no manifest. |
The same, read from stdin (-) |
The same finding, ending …prefers external metadata. It names no command, because relocate refuses stdin. |
| The page holds a key a manifest owns | No location finding. external:owned already fails there, whatever the mark. |
A manifest supplies a key its schema prefers page |
Finding location:page/location at the manifest line: "title" is stored in manifest docs-meta.yaml; steward.schema.json prefers the page. Run manni meta relocate. |
| The key sits where its mark says, or carries no mark | Nothing. |
The writers follow the manifest rather than the mark. A key a local manifest
owns is written there by derive, fill and query, even when its schema
prefers the page. A key its schema prefers external and no manifest owns is
offered a manifest on a terminal. Otherwise it is written to the page, with a
warning naming
manni meta relocate, the command
that moves values to where their marks say. Keep maintainer metadata out of
delivered pages is the journey.