Skip to content

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.

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.

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.json
type: guide
---
---
$schema:
- google:okf:0.1
- ./schemas/extra.schema.json
type: 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.json still resolves. A path that escapes it is refused.
  • A reference an operator supplied is never filtered. schemas, overrides[].schemas, and -s/--schema resolve 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.

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:// or https:// 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 as google: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.

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.

manni meta schemas vendor turns a URL reference into a file reference that keeps a record of where it came from:

manni.config.yaml
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…46cb
The 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 update
the 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.

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.

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 ttlHours since the file was last written, the next run re-fetches and rewrites it. Freshness reads the filesystem’s mtime rather than the fetchedAt recorded 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 (or offline: true) makes a run touch the network not at all:

  • A url reference 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 url reference that is not cached is an operational error (exit 2) naming the URL.
  • Built-in ids and local .json files 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.

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).

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.

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.

schemas/page.json
{
"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, an allOf, a referenced built-in, and the anyOf, oneOf and if/then branches the validator takes. A mark behind a failing if does 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 fill and a manni meta query UPDATE or INSERT write a marked top-level property encrypted. A nested mark is validated and never written.
  • A value other than true or false is 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.

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.

steward.schema.json
{
"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, an allOf, a referenced built-in, and the anyOf, oneOf and if/then branches the validator takes. A mark behind a failing if does 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’s id is 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.