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 checks these sources, in order, for the schema set of a file. The first one that yields a non-empty set is the one it uses.

LevelSourceScopeThe set it yields
1-s/--schema on the command lineAll files in the run.The listed schemas alone.
2$schema in the file’s own metadataThat file only. A repo can narrow what a document may name.The listed schemas alone.
3First matching config overrides entry with schemasFiles the entry matches. An entry whose files is a list matches on any glob in it, and still counts as one entry here.The entry’s schemas alone. With defaults: true, the default set first, then the entry’s schemas. The entry’s strict then applies.
4Config schemasAll files not matched above.The default set first, then schemas. With meta.defaults: false, schemas alone. meta.strict then applies.
5No config schemasEvery file, when nothing else applies.The default set. meta.strict then applies.

Once a level produces a set, lower levels are not consulted for that file. Within a resolved set, duplicate references are removed, and the first one keeps its place.

A listed version of a default vocabulary replaces the default’s version. With the defaults on, listing manni:stewardship:1.0.0 drops manni:stewardship:1.2.0, so each stewardship field is judged once.

An override never inherits config schemas. defaults: true on an entry asks for the default set, and nothing else from the levels below it.

The default set is eleven built-in schemas, in this order:

google:okf:0.1
passo-uno:seven-action:1.0
manni:core:1.0.0
manni:audience:1.0.0
manni:structure:1.0.0
manni:stewardship:1.2.0
manni:lifecycle:1.0.0
manni:ai-context:1.2.0
manni:evals:1.0.0
manni:graph:1.0.0
manni:citations:1.2.0

manni:core:1.0.0 requires title and description, so every file the defaults judge needs both. google:okf:0.1 requires type. The other nine constrain a field only when the file carries it. A field a vocabulary prefers in external metadata draws a location:external warning when it sits on the page. Warnings never fail a run.

manni:stewardship:1.0.0, manni:stewardship:1.1.0, manni:citations:1.0.0, manni:citations:1.1.0, manni:citations:1.3.0, manni:ai-context:1.0.0, manni:ai-context:1.1.0, manni:terminology:1.0.0, manni:terminology:1.1.0, manni:terminology:1.2.0, manni:artifact-evals:1.0.0, manni:artifact-evals:1.1.0 and every strict overlay stay out of the default set. A config lists them where they apply.

strict: true stacks a strict version right after each schema that has one. It applies at levels 3 to 5, through meta.strict or an entry’s own strict. It touches two kinds of schema:

  • A default. Each of the nine manni: defaults gets its overlay, such as manni:core-strict:1.0.0 after manni:core:1.0.0. OKF and Seven-Action have none. Another version of a default vocabulary counts too, so a listed manni:stewardship:1.0.0 gets manni:stewardship-strict:1.0.0.
  • A registered schema. A registered vendor:name-strict:version is the strict version of a registered vendor:name:version.

A listed built-in outside the default set is left as listed. tgdp:templates:1.1 stays open under strict: true. To close it, list tgdp:templates-strict:1.1 beside it.

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.documentRefsEffect on level 2
any (default)Unchanged. All three kinds, from any host unless schemaTrust.hosts is set.
localBuilt-in ids, registered 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.
noneLevel 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:

KindLooks likeResolved from
builtinvendor:name:version, e.g. google:okf:0.1A schema shipped with manni meta, or a registered schema with that $id.
fileA .json path or any path containing a separator, e.g. ./my.schema.jsonThe local filesystem.
urlAn http:// or https:// URLFetched over the network. Two exceptions answer with no fetch. A published built-in URL comes from manni meta’s own bundle. A registered URL $id comes from its registered file.

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.

An id that is neither shipped nor registered stops the run with exit 2. The message points at manni meta schemas for the built-in ids, and names the registered ones:

manni: Unknown schema "house:page:1.0.0". manni meta schemas lists the built-in ids. Registered by meta.register: none.

meta.register names local schema files, or directories of them, by path. Each file is loaded once and named by its own $id, which is either vendor:name:version or an absolute https:// URL.

A registered id works anywhere a built-in id works:

  • in config schemas and overrides[].schemas;
  • in -s/--schema;
  • in a document’s $schema, where it counts as a built-in under schemaTrust, because the config vouches for the file;
  • in a $ref from any schema, because every registered schema joins each validator before any schema compiles.

A registered URL id answers from its file, so no fetch is made. A registered id is reported by its $id, in findings and in manni meta schemas.

Registration belongs to the config. Under --no-config nothing registers, and a registered id is an unknown schema.

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 fromResolved relative to
schemas:, overrides[].schemas or register in a configThe config file’s directory
A document’s own $schemaThe current working directory
-s/--schema on the command lineThe 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.

A file reference ending in .flmeta is a MadCap Flare meta tag set, and meta reads it into a JSON Schema. It works in schemas, overrides[].schemas, -s/--schema and a document’s $schema.

  • Each <meta> is a string property. Its desc is the property’s description, and a finding prints it as guidance.
  • An Enum tag holds its list. Its value matches one or more of the tag’s enum values, joined by commas, as Flare writes a list of several. An empty value is Flare’s “not set”, and it passes.
  • Nothing is required, because Flare requires nothing.
✗ Content/Topics/Install.htm
/Status must match pattern "^((Draft|Reviewed|Published)(,(Draft|Reviewed|Published))*)?$" (line 6) [./Project/Advanced/Topics.flmeta]
./Project/Advanced/Topics.flmeta Status: Where the topic is in review.

A .flmeta file whose root element is not <CatapultMetaTagSet> stops the run with exit 2. manni reads a meta tag set and never writes one, so a meta query schema change that would write it exits 2 as well.

manni: <ref> is not a Flare meta tag set: its root element is <name>.
manni: <ref> is a Flare meta tag set, which manni reads and never writes.

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:

SituationWhat manni meta reports
The bytes differ only in line endingsThe 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 missingSchema 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 refRejected 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.

PropertyDetail
Location.manni/meta/schema-cache/, under the config file’s directory when a config governs the run, otherwise the working directory.
One file per URLNamed for a SHA-256 digest of the URL, holding the schema plus the URL and the fetch time.
FreshnessThe file’s mtime, against schemaCache.ttlHours (default 24).
Turning it offschemaCache.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:

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

DialectDetected from meta-schema URI containingNotes
Draft 2020-122020-12 (or anything unrecognized)The dialect of the built-in schemas, and the fallback.
Draft 2019-092019-09
Draft-07draft-07 / draft/7
Draft-06draft-06 / draft/6Shares the draft-07 validator.
Draft-04draft-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:

SituationOutcomeExit
The page holds a plain value, whatever the key stateFinding encrypted:plain/encrypted: /owner holds a plain value; its schema marks it x-manni-encrypt.1
A plain value an external-metadata manifest suppliedValidated as plain text and never flagged. The manifest is private by construction.0 or 1
An encrypted value a manifest suppliedThe 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 decryptsThe 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 decryptFinding 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 availableFindings 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:

SituationOutcome
The page holds a key its schema prefers externalFinding 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 collectionsThe same finding, ending …prefers external metadata, and this document is in none of the 2 collections.
The same, under --no-configThe 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 ownsNo location finding. external:owned already fails there, whatever the mark.
A manifest supplies a key its schema prefers pageFinding 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 markNothing.

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.