Configuration reference
A manni.config.yaml file has two levels, and the split is the point.
collections: at the top declares the document sets, once,
for every tool in the family to read. meta: holds what is the
metadata validator’s own business. That is the schema
set each file meets, per-collection
and per-glob overrides, cross-file checks, and the trust settings. With both in place CI can
run a bare manni meta validate. providers:, also at the top,
says which model provider every tool that sends content to a model uses.
The config is optional. Every key has a default, and manni meta runs without a config when you pass paths and schemas on the command line.
Migrating a 0.x config
Section titled “Migrating a 0.x config”Three keys left meta: in 1.0.0, and one key left overrides[]. Each is
refused, not aliased, with a message naming where it went. An alias would be
a permanent second place to declare a document set, which is the thing
collections: exists to end.
Before:
meta: paths: ["docs/**/*.md"] exclude: ["docs/drafts/**"] sidecars: - file: ./docs-meta.yaml keys: [source, jira] overrides: - name: guides files: "docs/guides/**" schemas: [./schemas/guide.json]After:
collections: - name: pages paths: ["docs/**/*.md"] exclude: ["docs/drafts/**"] externalMetadata: - file: ./docs-meta.yaml keys: [source, jira] - name: guides paths: ["docs/guides/**"]
meta: overrides: - collection: guides schemas: [./schemas/guide.json]| Was | Refused with | Write instead |
|---|---|---|
meta.paths | manni.config.yaml: "paths" is no longer a meta key. Document sets are declared once for every tool, under a top-level collections: list. See …#collections | collections[].paths |
meta.exclude | manni.config.yaml: "exclude" is no longer a meta key. Document sets are declared once for every tool, under a top-level collections: list. See …#collections | collections[].exclude, whose meaning narrowed. See Membership |
meta.sidecars | manni.config.yaml: "sidecars" is no longer a meta key. It is externalMetadata on a collection, under the top-level collections: list. See …#external-metadata | collections[].externalMetadata |
overrides[].name | manni.config.yaml: overrides[0] no longer carries "name". Define a collection with that name and point the override at it with collection:. | A collection with that name, and overrides[].collection pointing at it |
Each message prints the reference URL in full; it is elided above only to keep
the table readable. All four are exit 2, and all four fire for a legacy
docmeta.config.yaml too, since that file’s whole document is the meta:
section.
Three more things changed with the keys.
- The word. A sidecar is now external metadata, and the file it lives in is a manifest. Nothing about the mechanism changed; every rule below is the rule it was.
- The two finding identities are
external:owned/externalandexternal:duplicate/external, where they weresidecar:owned/sidecarandsidecar:duplicate/sidecar. A baseline recorded before 1.0.0 stops matching those two. The findings reappear, and the run is exit 1 until you regenerate it with--write-baseline. - A collection’s
exclude:no longer filters a path you type. Undermeta.excludeit filtered every run. It is now a membership rule, and only--excludereshapes a run. See Membership.
Collections
Section titled “Collections”A collection is a named set of documents, declared once at the top level and read by every tool that operates on documents. It carries the globs that select its files, the globs that exclude them, where the set is published, and the external metadata joined to it.
collections: - name: guides paths: ["docs/guides/**/*.md"] exclude: ["docs/guides/drafts/**"] url: https://docs.example.com/guides/ externalMetadata: - file: ./guides-meta.yaml keys: [source, jira] join: id - name: blog paths: ["blog/**/*.{md,mdx}"] url: https://docs.example.com/blog/| Key | Type | Required | Default | Description |
|---|---|---|---|---|
name | string | yes | none | What --collection, overrides[].collection and FROM <name> in SQL refer to. Non-blank, unique case-insensitively, never docs, derived, _derived_rows or resolved in any case, and never starting sqlite_. |
paths | string[] | yes | none | Files, directories, or globs, relative to the config file’s directory. A non-empty list of non-empty strings. |
exclude | string[] | no | [] | Globs that remove a file from the collection, same base. Membership only. See Membership. An explicit [] is accepted. |
url | string | no | none | One http: or https: root, where the collection is published. manni a11y check seeds from it. See Where a collection is published. |
externalMetadata | ExternalMetadata[] | no | [] | Manifests joined to this collection’s members. See External metadata. An explicit [] is accepted. |
collections: absent means no configured documents, exactly as an absent
paths: did, and a run then needs paths on the command line. An explicit
collections: [] is refused, because a key someone wrote and left empty reads
as configured and is not.
Every name becomes a SQL view name in
query, which is where
four of the name rules come from. SQLite compares identifiers without regard to
case, so two names differing only in case would be one view. docs is the table
every query reads. derived, _derived_rows and resolved are the views and
backing table a query builds for derived metadata, refused in any
case.
sqlite_ is a prefix SQLite reserves.
The refusals, each exit 2:
manni.config.yaml: "collections" must be a list.manni.config.yaml: "collections" must name at least one collection; remove the key if there are none.manni.config.yaml: collections[0] must be a mapping.manni.config.yaml: collections[0] has unknown key "path". Supported keys: name, paths, exclude, url, externalMetadata.manni.config.yaml: collections[0].name must be a non-empty string.manni.config.yaml: collections[1].name "guides" is already taken by collections[0]; names are compared case-insensitively because they become SQL views.manni.config.yaml: collections[0].name "docs" collides with the docs table every query reads. Pick another name, such as "site" or "pages".manni.config.yaml: collections[0].name "derived" collides with the derived table a query builds beside docs (the derived view, its _derived_rows table, and the resolved view over both). Pick another name.manni.config.yaml: collections[0].name "sqlite_x" starts with "sqlite_", which SQLite reserves for its own objects. Pick another name.manni.config.yaml: collections[0].paths must be a non-empty list of files, directories or globs.manni.config.yaml: collections[0].exclude must be a list of globs.manni.config.yaml: collections[0].url must be an http(s) URL.manni.config.yaml: collections[0].externalMetadata must be a list.collections: is a top-level key, so these are never prefixed with a section
name. A meta: error reads manni.config.yaml: meta.overrides[0] …; a
collections error reads manni.config.yaml: collections[0] …, whether the file
is a family file or an explicit -c.
Selecting collections on the command line
Section titled “Selecting collections on the command line”With no positional paths, a run reads every collection, in declaration
order. --collection <name> narrows it. In meta it is on validate, get,
query, fill, derive, relocate and schemas infer. The other domains
take it wherever they read documents, so cite check, cite update,
cite remove, a11y check, key rotate and the term verbs all have it.
manni meta validate # every collectionmanni meta validate --collection guides # onemanni meta validate --collection guides --collection blog # twoIt takes one name per occurrence and never splits on commas, so
--collection guides,blog looks for a collection with that literal name and
does not find one. Repeats collapse, names are matched exactly, and the run
visits the collections in the order the config declares them however you
ordered the flags.
Three usage errors, each exit 2:
$ manni meta validate --collection gidesmanni: no collection named "gides" in manni.config.yaml. Configured: guides, blog.
$ manni meta validate --collection guides docs/x.mdmanni: --collection selects a configured collection; it cannot be combined with paths.
$ manni meta validate --collection guides --no-configmanni: --collection needs a config file to select from.- (stdin) is allowed beside --collection, because stdin is one more input
rather than a reshaping of the configured corpus. The named collection is still
covered: the run reads the piped document and walks the collection’s files. A
bare - with no flag is a run of its own, because the fallback below is a
default rather than a request.
A run narrowed by --collection is a scoped run, so it skips the
corpus checks with a notice naming the collections. It does
not run a FROM blog rule over a corpus that does not contain the blog:
$ manni meta validate --collection guidesmanni: corpus checks skipped: run is scoped to collections guidesMembership
Section titled “Membership”Membership decides which collection’s manifests a file takes, which view it appears in, and whether an override naming a collection matches it. It is decided per file, and it is decided the same way whether the file came from the walk or from the command line.
- A positional path is a file the operator chose. It is loaded whether or
not any collection contains it.
manni meta validate README.mdvalidatesREADME.mdeven where no collection mentions it. It simply gets no manifests, and resolves its schema from a matching glob override, or from the default set andschemas:. - Membership is the collection’s own globs. A file belongs to a collection
when, relative to the config file’s directory, it matches one of that
collection’s
pathsand none of itsexclude. Nothing about the schema the file is judged by affects it. - It is path arithmetic, not a filesystem question. A
pathsentry containing glob metacharacters is matched as a glob. Any other entry matches itself and everything beneath it, which is how a bare directory and a bare filename both work.excludeentries are always matched as globs. Nostatis involved, so membership costs nothing per file and cannot fail. A path outside the config’s directory, and stdin, are members of nothing. - A file may belong to two collections. It appears in both views, takes the manifests of both, and still resolves exactly one schema set by first-match-wins. Two collections owning the same external-metadata key is fine until one file is a member of both. That case is exit 2, and rule 5 says so.
- A collection’s
excludenever filters a path you type. Only--excludedoes. The tool has no basis for picking which collection’s exclusions to honour once a typed directory spans several, and an operator who typesdocs/chosedocs/.
Where a collection is published
Section titled “Where a collection is published”url: is the site root the collection’s documents appear at. It is a fact about
the documents rather than a setting of any one tool, which is why it sits beside
paths:. It is what manni a11y check checks when it is given no URLs of its own.
collections: - name: guides paths: ["docs/guides/**/*.md"] url: https://docs.example.com/guides/Seeds are decided in one order, and the first non-empty source wins:
- positional URLs on the command line;
- the
urlof each collection--collectionnamed; a11y.urls;- the
urlof every declared collection that has one.
a11y.urls stays, because a site has entry points no documentation collection
covers, such as a marketing page, a status page, or a staging host. A collection
with no url contributes nothing, and is an error only when --collection
named it.
External metadata
Section titled “External metadata”externalMetadata is a list of YAML manifests joined to the collection’s
members. Each supplies the top-level keys it owns for named
documents. The values are merged into each document’s extracted metadata before
schema resolution, so validate, get, query and fill all see one object.
A manifest is never a document itself. No extractor reads it, and it has no row
in the docs table. It is a join table.
External metadata is frontmatter values kept outside the document and merged at
run time. The document never holds the value. The contract on it stays with the
document, in the same schema set, checked by the same validate run, with the
same exit code. Three uses share the one mechanism:
- Lean documents. A page carries what it is about. The design note it was written from, the ticket that tracks it, its owner and its review state live in the manifest. A contributor fills in fewer fields, and a reader or an agent that ingests the page sees none of the bookkeeping.
- Private values for public documents. The manifest lives in a private repository. Public CI never sees it, and the private run validates the public pages against it.
- Across repositories. One repository’s pages take keys from a manifest in another, by URL, public or private. See Remote manifests.
- Citations. A manifest that owns
citationskeeps a collection’s citation entries out of the pages. This is the one key a tool other thanmetareads, and writes. See A manifest that owns citations.
Keep metadata outside the document is the journey, with a worked path for each; this section is the contract.
| Field | Type | Required | Description |
|---|---|---|---|
file | string | yes | Manifest path, relative to the config file’s directory, or an https:// URL fetched at the start of every run. A path may hold one {page} placeholder, which gives each page a manifest of its own; see A manifest per page. A manifest that cannot be read or fetched, or is not valid YAML, is an operational error (exit 2). A missing {page} manifest is the one exception, and reads as empty. See Remote manifests. |
keys | string[] | no | The top-level keys this manifest owns. Non-empty, no duplicates, disjoint across the collection’s manifests, and never $schema. Each violation is a config error (exit 2) naming collections[c].externalMetadata[i].keys. Absent, the manifest owns the fields each page’s schemas mark x-manni-location: external, and at most one entry per collection may leave it out. See A manifest with no keys. |
tokenEnv | string | no | Name of an environment variable whose value is sent as a bearer token when file is a URL. Allowed only on a URL. On a path it is a config error (exit 2), because no request is made and the token would go nowhere. |
join | string | no | path (the default), or the top-level frontmatter field the manifest’s keys are values of. Never $schema, and never a key the same entry owns. A rename cannot orphan a field-joined entry. See Joining by a field. |
collections: - name: pages paths: ["docs/**/*.md"] externalMetadata: - file: ./docs-meta.yaml keys: [source, jira]
meta: overrides: - collection: pages schemas: [./schemas/page.json] # requires `source` and `jira`externalMetadata: absent is exactly the behaviour every config had before the
key existed. A repository whose manifest lives elsewhere keeps its own plain
config and never mentions one. That way none of its runs warns about a file it
cannot see.
The config refusals, each exit 2:
manni.config.yaml: collections[0].externalMetadata[0] must be a mapping.manni.config.yaml: collections[0].externalMetadata[0] has unknown key "keyz". Supported keys: file, keys, tokenEnv, join.manni.config.yaml: collections[0].externalMetadata[0].file must be a non-empty string naming the manifest, relative to the config file.manni.config.yaml: collections[0].externalMetadata[0].keys must be a non-empty list of key names.manni.config.yaml: collections[0].externalMetadata[0].keys lists "jira" twice.manni.config.yaml: collections[0].externalMetadata[0].keys may not include "$schema" — a manifest never chooses the schema a document is judged by; use "overrides".manni.config.yaml: collections[0].externalMetadata[1].keys claims "jira", which externalMetadata[0] already owns — a key has exactly one manifest in a collection.manni.config.yaml: collections[0].externalMetadata: entries 1 and 2 both omit keys; at most one manifest may own the external-marked fieldsmanni.config.yaml: collections[0].externalMetadata[0].join must be "path" or the name of a top-level frontmatter field.manni.config.yaml: collections[0].externalMetadata[0].join names "id", which the same entry owns — the value that selects an entry cannot come from the entry.manni.config.yaml: collections[0].externalMetadata[0].file uses "{collection}", which is not a placeholder. The only placeholder is {page}.manni.config.yaml: collections[0].externalMetadata[0].file contains "{" outside a {page} placeholder. Remove it, or write {page}.manni.config.yaml: collections[0].externalMetadata[0].file uses {page} twice. One manifest names one page.manni.config.yaml: collections[0].externalMetadata[0].file uses {page} in an absolute path, so every manifest would sit outside the repository. Make it relative to the config file.manni.config.yaml: collections[0].externalMetadata[0].file uses {page} in a URL. A placeholder names a file this run can write, and a URL is not one.manni.config.yaml: collections[0].externalMetadata[0].file uses {page}, and join is "id". A per-page manifest is found by path, so it cannot be keyed by a field. Remove {page}, or remove join.manni.config.yaml: collections[0].externalMetadata[1].file resolves to the same manifest as externalMetadata[0].file for every page. Give each entry its own file name.The manifest
Section titled “The manifest”A manifest is a YAML mapping from document to a mapping of owned key to value. A document is named by path (the default) or by a field. By path:
docs/guides/auth.md: source: internal/auth-design.md jira: PLAT-412docs/guides/billing.md: source: internal/billing.md jira: PLAT-388Document paths are exact, relative to the config file’s directory. There are no globs and no cascade. A silently merged result changes what the contract means, and a manifest has no specificity rule to decide which of two matching patterns wins. “Every guide gets X” is not a manifest feature. Joining by a field names documents by a frontmatter value instead, so a rename never orphans an entry.
An empty manifest is legal and merges nothing. Every other shape problem is an
operational error (exit 2) naming the manifest and, where there is one, the
entry’s line. From test/fixtures/external-metadata/:
| Problem | Message |
|---|---|
| The top level is not a mapping. | Manifest bad-not-mapping.yaml: the manifest must be a mapping from document path to owned keys. |
| An entry is not a mapping. | Manifest bad-entry-scalar.yaml:1: "docs/auth.md" must be a mapping of owned keys to values. |
| A document is named twice. | Manifest bad-duplicate-path.yaml:5: "docs/auth.md" is named twice (first at line 1). Merge the two entries into one. |
An entry sets a key outside keys:. | Manifest bad-unowned-key.yaml:1: "docs/auth.md" sets "team", which this manifest does not own. Add it to the manifest's "keys", or remove it from the entry. |
An entry sets $schema. | Manifest bad-schema-key.yaml:1: "docs/auth.md" sets "$schema" — a manifest never chooses the schema a document is judged by; use "overrides" in the config. |
The file is missing, and file holds no {page}. | Manifest nope.yaml could not be read: ENOENT: no such file or directory, … |
A field-joined manifest reports the same shapes with its
own noun: the manifest must be a mapping from document "id" to owned keys.
The loader keeps the line of each entry and of each item inside it, not
only the line of the owned key. So a tool that finds a problem in the third
citation of a page can report the line that citation sits on. validate’s own
findings still sit on the owned key’s line, because a schema judges the merged
value as a whole.
A manifest per page
Section titled “A manifest per page”file may hold one placeholder, {page}. For each page, it resolves to the
page’s path relative to the config file’s directory, without its extension,
with forward slashes. The entry then names one manifest per page:
file | The manifest for docs/guides/auth.md |
|---|---|
"{page}.meta.yaml" | docs/guides/auth.meta.yaml, beside the page |
"./meta/{page}.meta.yaml" | meta/docs/guides/auth.meta.yaml, in a tree that mirrors the pages |
./docs-meta.yaml | docs-meta.yaml, one file for every page |
collections: - name: pages paths: ["docs/**/*.md"] externalMetadata: - file: "{page}.meta.yaml" # quoted, because a bare { opens a YAML mapping keys: [source, jira]A per-page manifest has the shape of any other. It holds one entry, keyed by its own page’s path:
docs/guides/auth.md: source: internal/auth-design.md jira: PLAT-412The sibling layout is the one to reach for. Two branches that edit two pages touch two manifests, so they merge without a conflict, and a reviewer finds a page’s values beside it.
A per-page manifest that does not exist reads as empty, so a page with nothing
kept there needs no file. One that exists and cannot be read is still an
error. A file without {page} keeps its meaning, one manifest for the
collection, and a missing one is an error.
The first write that stores a value for a page creates its manifest, and the directory it sits in.
Two spellings of one pattern, such as "{page}.yaml" and "./{page}.yaml",
are one pattern, and two entries may not share it. A page may not resolve
outside the config file’s directory. The config refusals are in the list
above. These are raised once the pages are known, each exit 2:
| Problem | Message |
|---|---|
| A page the collection claims sits above the config file’s directory. | manni.config.yaml: collections[0].externalMetadata[0].file resolves outside manni.config.yaml's directory for "../site/install.md". A {page} manifest stays under the config file. |
| Two pages differ only in extension, so both resolve one manifest. | docs/install.md and docs/install.mdx both resolve {page} to docs/install.citations.yaml. A {page} manifest names one page, so rename one of them. |
| A manifest names a page other than the one it resolved for. | Manifest docs/index.citations.yaml:1 names "docs/other.md", but {page} resolved this file for "docs/index.md". A per-page manifest holds one entry, for its own page. |
| On a whole-collection run, a file the pattern matches names a page the run did not load. | Manifest docs/old.citations.yaml:1 names "docs/old.md", which this run did not load. Fix the entry, or remove the file. |
The last is the orphan check for a per-page manifest. A
git mv that leaves a page’s manifest behind is the usual cause, so move the
two together.
A manifest with no keys
Section titled “A manifest with no keys”An entry may leave keys out. It then owns the fields each page’s schemas
mark x-manni-location: external, the
field location mark
that validate warns on and relocate follows. When a vocabulary gains an
external field, the manifest owns it with no config edit.
collections: - name: site paths: ["docs/**/*.md"] externalMetadata: - file: "{page}.citations.yaml" keys: [citations] - file: "{page}.meta.yaml" # owns the other external fieldsOwnership is decided per page, from the page’s resolved schema set. An override that gives some pages another set gives their manifest other fields. Four rules settle the rest.
- Written keys win. A field another manifest of the collection names in
its
keysbelongs to that manifest. Above,citationsstays in{page}.citations.yaml, although the citations vocabulary marks it external. - The join field stays on the page. A manifest joined on a field reads that field from the page, marked or not.
- Nothing marked, nothing owned. A field the page’s schemas do not mark
external is not the manifest’s. An entry that sets one anyway is refused,
exit 2, as an entry setting a key outside
keysis. A page whose schema set fails to resolve owns nothing through the manifest, and its entry is not judged. - At most one per collection. Two entries without
keyswould claim the same fields, so the config is refused, exit 2. The message is in the refusals list above.
Everything downstream reads the same as for a manifest with keys. A page
carrying a field the manifest owns is external:owned.
get and query read the manifest’s value. relocate moves a marked field
into it without editing keys:, and derive stamps a managed field there. A
URL manifest may omit keys too, and a write that would land in it is refused,
as for any key a URL manifest owns.
An entry may set only the fields its manifest owns for the page. Each refusal is exit 2.
| Problem | Message |
|---|---|
| The entry sets a field another manifest names. | Manifest docs/guide.meta.yaml:1: "docs/guide.md" sets "citations", which another manifest of this collection names in its "keys". Move it to that manifest, or remove it from the entry. |
| The entry sets the field its manifest joins on. | Manifest site-meta.yaml:1: "install" sets "slug", the field this manifest joins on, which the page carries. Remove it from the entry. |
| The entry sets a field the page’s schemas do not mark external. | Manifest docs/guide.meta.yaml:1: "docs/guide.md" sets "status", which this manifest does not own, because the page's schemas do not mark it x-manni-location: external. Mark it external in the page's schemas, or remove it from the entry. |
A manifest that owns citations
Section titled “A manifest that owns citations”citations is the one owned key another tool in the family reads, and the one
a tool writes. A collection that declares a manifest owning it keeps its
pages’ citation entries there, and the pages carry none:
collections: - name: site paths: ["docs/**/*.{md,mdx}"] externalMetadata: - file: docs-citations.yaml keys: [citations]docs/limits.md: citations: - id: fetch-timeout claim: { lines: 3, integrity: sha256-c41f09aa… } source: { file: lib/limits.ts, lines: 2, integrity: sha256-78af1d33…, commit-sha: 3f9c2a1e… }manni cite check, add and update, and manni key rotate, read a page’s
citations from the manifest that owns them, through the merge above. add,
update and key rotate also write it, splicing only that page’s citations
value and reading the file back to confirm. A meta fill or meta query
write to it lands in the manifest too, as for any owned key.
Two rules are particular to this key:
- A URL manifest may not own
citations. It is a config error (exit 2), because cite writes citations and a URL cannot be written. It would also put private source paths into public CI output. - A finding about a citation entry sits on the manifest. It carries the manifest’s path and the entry’s own line, which is what the per-item lines above are for. A claim or marker finding still sits on the page. SARIF drops a location outside the repository, so a manifest kept outside it is reported on the page instead.
A page that still carries its own citations: while a manifest owns the key
is external:owned in validate, and entry-invalid in
cite check. The citations reference
has the entry shape and every refusal.
Joining by a field
Section titled “Joining by a field”join: <field> keys the manifest by a top-level frontmatter field instead of
a path. The page carries the key with it, so a rename never orphans an entry.
join absent, or join: path, is the path behaviour above.
collections: - name: pages paths: ["docs/**/*.md"] externalMetadata: - file: ./docs-meta.yaml keys: [source, jira] join: idauth-guide: source: internal/auth-design.md jira: PLAT-412billing-guide: source: internal/billing.md jira: PLAT-388Any top-level field works. A hand-written id, a Starlight slug, and a
Docusaurus id are one mechanism with no special cases. Matching is on the
extracted value, so it works for every format and for a remote manifest. The
value is compared as a string, so id: 42 matches the manifest key 42. A
value that is not a scalar matches nothing.
Require the field in the schema. A page without the field matches
no entry, and nothing says so. What catches it is the ordinary required
finding on the merged object, which only exists when a schema demands the
manifest’s key. Require the join field too, and give it a pattern, so an id is
at least well-formed:
{ "type": "object", "required": ["title", "id", "jira"], "properties": { "id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, "jira": { "type": "string", "pattern": "^PLAT-[0-9]+$" } }}The rules a field join adds:
- Two pages sharing a value is a finding on both. One entry matched two
documents, and the manifest cannot tell them apart. The finding carries
the schema ref
external:duplicate, the keywordexternal, and the value assubject. Its instance path is/<field>, at the field’s line in each page, under the rule idexternal:duplicate/external. It fires whether or not the run is scoped, because it is about the pages loaded. Both pages still receive the entry’s values, so the schema judges what the site would publish. - A field entry no page matched is exit 2 on a corpus run. The values are only known once every document is read, so the check runs after the per-file loop. A scoped run skips it, as the orphan check does for a path entry.
- The join field is never an owned key.
joinmay not be$schema, and it may not name a key the same entry owns. The value that selects an entry cannot come from the entry. Each is a config error (exit 2) namingcollections[c].externalMetadata[i].join. pathis the join, not a field. A frontmatter key literally namedpathcannot be joined on. Nothing else about the value is reserved.- Changing the field is a write to the join.
query’sUPDATEof the join field on a document that has an entry is refused before any file is written. Renaming that document is allowed, which is the point.
From test/fixtures/external-metadata-join/, where dup-a.md and dup-b.md
both say id: shared:
✗ docs/dup-a.md /id 2 documents carry id "shared"; docs-meta.yaml cannot tell them apart (docs/dup-b.md) (line 3) [external:duplicate]✗ docs/dup-b.md /id 2 documents carry id "shared"; docs-meta.yaml cannot tell them apart (docs/dup-a.md) (line 3) [external:duplicate]The same directory carries a second config whose manifest names an id no page carries. A corpus run stops on it:
$ manni meta validate -c manni.orphan.config.yamlmanni: Manifest docs-meta.orphan.yaml:3 names id "gone-guide", which no loaded document carries. Fix the entry, or remove it. exit 2And the query refusal, on the default config:
$ manni meta query "UPDATE docs SET id = 'other' WHERE _path = 'docs/auth.md'"manni: "docs/auth.md": "id" is the field manifest docs-meta.yaml joins on, and this document has an entry; change the manifest first. exit 2The join key is contributor-controlled. A pull request that sets
id: auth-guide on a new page inherits every assertion the manifest makes
about the real one. The schema cannot tell the two apart. Three things bound
that. The duplicate finding fires on both pages, so the run goes red rather
than green. The schema’s pattern keeps an id well-formed. And the manifest
is reviewed on its own, in the private repository when there is one. An entry
that suddenly matches two pages shows up there as a duplicate finding, before
it shows up anywhere else.
A path join has none of this exposure, which is why it stays the default.
Remote manifests
Section titled “Remote manifests”file may be an https:// URL instead of a path. The manifest is fetched at
the start of every run, parsed exactly as a local one is, and merged by the
same rules. Its document paths still resolve from the config file’s directory.
Nothing downstream of the loader changes. Two layouts use it.
Across public repositories. One public repository’s pages take keys from a manifest in another public repository. Nothing is secret on either side, and the run never checks the second repository out. A plain URL is the whole configuration:
collections: - name: pages paths: ["docs/**/*.md"] externalMetadata: - file: https://raw.githubusercontent.com/org/docs-catalog/main/owners.yaml keys: [team, owner]A private manifest. The same URL form, plus tokenEnv, the name of an
environment variable holding a bearer token. Its value is sent as
Authorization: Bearer <value> to the origin the config names, and nowhere
else. The config never holds the token:
collections: - name: public-pages paths: ["public/docs/**/*.md"] externalMetadata: - file: https://raw.githubusercontent.com/org/private-docs/main/docs-meta.yaml keys: [source, jira] tokenEnv: PRIVATE_DOCS_TOKENBoth hosts accept a bearer token on their raw-file route, so one tokenEnv
covers either:
GitHub https://raw.githubusercontent.com/OWNER/REPO/REF/PATHGitLab https://gitlab.com/api/v4/projects/ID/repository/files/PATH/raw?ref=REF| Host | Token |
|---|---|
| GitHub | A fine-grained personal access token with read access to the repository’s contents. |
| GitLab | A project or personal access token with the read_repository scope. PATH is URL-encoded, so docs/docs-meta.yaml is written docs%2Fdocs-meta.yaml. |
The rules a remote manifest adds:
- Fetched every run, never cached. The schema cache
exists because a schema changes rarely. A manifest changes with every page,
and a stale copy validates the corpus against the wrong values.
schemaCachedoes not apply to a manifest. - A fetch that fails is exit 2, naming the URL and the status. A
401or403says the token is wrong. A404says the URL is, or that the token is missing, and the message says which. A network error says so. The token never appears in a message. --offlinerefuses a remote manifest, exit 2. By rule 1 there is no cached copy to fall back to. A config with one local and one remote manifest refuses before reading either.- The credential lives in the environment, never in the config. A URL
carrying userinfo (
https://user:secret@host/…) is a config error at parse time, because the URL is printed in every diagnostic.tokenEnvon a pathfileis a config error too, because no request is made. http://is refused, except on a loopback host. A bearer token over plaintext is a leak, and a public manifest is served overhttps://too.localhostand127.0.0.1are allowed, so a local server can serve one.- A redirect keeps the token only on the same origin. A cross-origin
redirect is followed without the header, the way browsers and
curlbehave. A manifest that lands where the token does not reach fails as a401, which rule 2 reports. - The URL is operator config, so it is trusted as such. There is no host
allowlist for a manifest URL, and
schemaTrustdoes not apply to it.$schemainside a fetched manifest is still refused. - A finding names the URL as the file. A finding on a fetched value carries the URL and the manifest line. Pretty, GitHub and JUnit print it. SARIF drops that finding with the existing outside-the-repository notice, because a URL is not a repository-relative location. The document-side findings of the same run are unaffected.
- Same bounds as the schema fetch.
A 10-second timeout, a 5 MB body cap, and one retry on a network error or
a
5xx. A4xxis not retried.
A fetched value that fails its schema reports the URL where a local manifest reports its path:
✗ public/docs/guides/billing.md /jira must match pattern "^PLAT-[0-9]+$" (https://raw.githubusercontent.com/org/private-docs/main/docs-meta.yaml:6) [./schemas/private.json]The operational errors, each exit 2. The URL is abbreviated here, and printed whole by the message:
| Problem | Message |
|---|---|
The variable tokenEnv names is not set. | Manifest https://…/docs-meta.yaml: the environment variable PRIVATE_DOCS_TOKEN named by "tokenEnv" is not set. |
| The host answered 404 to an anonymous request. | Manifest https://…/docs-meta.yaml could not be fetched: HTTP 404 (a private file answers 404 without a token; set "tokenEnv"). |
| The host answered 404 with a token sent. | Manifest https://…/docs-meta.yaml could not be fetched: HTTP 404 (a private file answers 404 without a valid token). |
| The host answered any other non-2xx status. | Manifest https://…/docs-meta.yaml could not be fetched: HTTP 403. |
| The host did not answer within the timeout. | Manifest https://…/docs-meta.yaml could not be fetched: timed out after 10000ms |
| The body exceeds the cap. | Manifest https://…/docs-meta.yaml is too large: the response exceeds the 5242880-byte limit. |
The run is --offline. | Manifest https://…/docs-meta.yaml is remote and the run is offline. Vendor it to a path, or drop --offline. |
tokenEnv is set on a path file. | manni.config.yaml: collections[0].externalMetadata[0].tokenEnv is set, but "file" is a path, so no request is made and the token would go nowhere. Remove it, or make "file" a URL. |
file is http:// on a public host. | manni.config.yaml: collections[0].externalMetadata[0].file is plain http://; a bearer token over plaintext is a leak, and a public manifest is served over https too. |
file carries userinfo. | manni.config.yaml: collections[0].externalMetadata[0].file carries a credential in the URL; put the token in an environment variable and name it with "tokenEnv". |
The rules
Section titled “The rules”-
Keys are owned, per collection. Each entry declares the top-level keys it supplies. Ownership is disjoint across a collection’s entries, so two of its manifests can never disagree about one key. The config parser refuses a second claim:
collections[0].externalMetadata[1].keys claims "jira", which externalMetadata[0] already owns — a key has exactly one manifest in a collection.An entry with nokeysowns what each page’s schemas mark external. See A manifest with no keys. No manifest may own$schema. External metadata never feeds schema resolution; the document’s contract comes fromoverridesand the rest of the precedence chain. -
A document carrying an owned key is a finding. Neither channel wins. The discarded value would be exactly the one nobody checked. The finding attaches to the document at the key’s line, under the rule id
external:owned/external, and names the collection the manifest belongs to. The document’s value is what the schema sees for that run, so the report shows what the public site would publish. -
Manifest keys name documents by path (the default) or by a field. A path is exact, relative to the config’s directory, and matched on the resolved absolute path, never on the spelling. A positional run from a subdirectory therefore finds the same entry a corpus run does. Stdin never has a path entry, because there is no file behind it. A field key matches the page’s own value of that field, wherever the page lives. See Joining by a field.
-
An entry naming a document the run did not load is exit 2. A named input that is not there is an error, not a silent pass, and a manifest entry is a named input. See the orphan check for when it runs.
-
Two collections may own one key, but not for one file.
guidesandblogcan each have a manifest supplyingowner; that is ordinary. If one loaded file is a member of both, and both own the key, the run is exit 2. It names the file, the key and both collections. A precedence rule here would be a tiebreak, and manni meta refuses tiebreaks between metadata channels. The fix is in the config, where the overlap was declared:manni: docs/api/auth.md: "owner" is owned by manifests in two of its collections, guides (guides-meta.yaml) and api (api-meta.yaml); a key has one manifest per file. Narrow one collection's paths or exclude.exit 2 -
A violation on a supplied value names the manifest. The finding’s subject file stays the document, because that is its baseline identity. The error carries the manifest file and line as where the value is, and every reporter prints it.
-
An owned key is written where it is read.
derive,fillandquery’sUPDATE,DELETE,INSERTand key rename write an owned key into the document’s entry in its local manifest, and never into the page. When the manifest is private, writing the key into a public document would leak the value. A URL manifest is fetched and cannot be written. A write to a key it owns is refused with"owner" is owned by manifest https://example.com/owners.yaml, which is fetched and cannot be written; set it in that repository.So isALTER TABLE … RENAME COLUMNof an owned key, sincekeys:would need renaming too. -
relocateeditskeys:and moves the values with it. Adding a key tokeys:moves it out of every member page, and removing one moves it back in.manni meta relocatedoes both halves, following each property’sx-manni-locationmark, and writes what a move needs into this file. See what relocate writes.
What each command does
Section titled “What each command does”| Command | With external metadata |
|---|---|
validate | Merges, validates the merged object, and files a finding for every owned key a document carries. Runs the orphan check on a corpus run, including one narrowed with --collection. |
get | Reads the merged value. manni meta get jira docs/auth.md prints docs/auth.md: jira=PLAT-412 from the manifest as readily as it would from the document. It does not say which of the two supplied it. |
query | Every row holds the merged values, so SELECT _path, jira FROM docs and a corpus check see the manifest’s keys. A write to an owned key lands in the document’s manifest entry, and the change names the manifest. Three writes refuse (exit 2) before any file is written. They write a key a URL manifest owns, RENAME COLUMN an owned key, or change a matched document’s join field. |
fill | A candidate property a local manifest owns is written into the document’s entry there. One a URL manifest owns is a per-file error (exit 1) rather than a skip. A missing owned key is never quietly left missing. |
derive | A managed field a local manifest owns is compared against the manifest’s value and stamped there. One a URL manifest owns is refused when the config loads (exit 2). See a field a manifest owns. |
relocate | Moves values between the pages and the manifest, and edits keys:, paths: and collections: to match. See what relocate writes. |
The validate run over test/fixtures/external-metadata/, whose
docs-meta.yaml supplies jira for three of four pages and one page carries
jira: itself:
✓ docs/auth.md✗ docs/billing.md /jira must match pattern "^PLAT-[0-9]+$" (docs-meta.yaml:6) [./private.schema.json]✗ docs/new.md (root) must have required property 'jira' (line 1) [./private.schema.json]✗ docs/ops.md /jira "jira" is owned by manifest docs-meta.yaml (collection pages); remove it from the document (line 3) [external:owned]
4 files checked, 1 passed, 3 failed, 3 errorsbilling.md is rule 6: the schema rejected a value the manifest supplied, and
the location is the manifest’s line. new.md is an ordinary required
violation, because presence is what schema required already says. A missing
entry surfaces as a finding the baseline, SARIF and the ratchet already
understand. ops.md is rule 2, and its message names pages, the collection
whose manifest owns the key.
The writes, on the same corpus. query writes an owned key, and a moved
document’s entry, to the manifest:
$ manni meta query "UPDATE docs SET jira = 'PLAT-1' WHERE _path = 'docs/auth.md'" --dry-rundocs/auth.md: jira: PLAT-412 -> PLAT-1 [docs-meta.yaml]1 change across 1 file — dry run; run again without --dry-run to apply$ manni meta query "UPDATE docs SET _path = 'docs/auth2.md' WHERE _path = 'docs/auth.md'" --dry-rundocs/auth.md -> docs/auth2.md (moved) [docs-meta.yaml]1 change across 1 file — dry run; run again without --dry-run to applyfill writes the same way. source is missing from billing.md, new.md
and ops.md, and the schema lists it, so it is a candidate on each. A
candidate a manifest owns has exactly one honest destination. So a proposal
fill accepts is spliced into the document’s entry in docs-meta.yaml, and
the page is left as it was. The report names the manifest after the value:
/source runbooks/billing 0.92 → docs-meta.yaml. A candidate a URL
manifest owns refuses the file (exit 1), because a fetched manifest cannot be
written. The message is "source" is owned by manifest https://example.com/docs-meta.yaml, which is fetched and cannot be written; set it in that repository. auth.md
has both keys in the manifest and is left alone.
What relocate writes
Section titled “What relocate writes”manni meta relocate, and the
offers validate, derive, fill and query make on a terminal, are the
only commands that edit collections:. Config gains no new key: each edit is
to paths:, externalMetadata: or keys:, through the same
comment-preserving writer schemas vendor uses. Keys are listed in the order
the run first met them.
| Situation | Edit |
|---|---|
| The page’s collection has a local manifest | The keys are appended to that entry’s keys:. With several collections, the first in collections: order is used. |
| The collection has no local manifest | <collection>.metadata.yaml is created beside the config file, and an entry owning the keys is added. A file already at that path that is not declared is an error (exit 2). |
| No collections are defined | A collection named default is added. Its paths: are the run’s targets as typed: docs/ becomes docs/**, and a file or glob stays itself. |
| Exactly one collection, and the page is outside it | The run’s target is appended to that collection’s paths:. What manni a11y and manni cite see for the collection grows with it. |
| Several collections, and the page is in none | Nothing is edited. The value stays and is named. |
| A key moves back into the pages | It is removed from keys:. An entry left with no keys is removed, and so is an empty externalMetadata:. The manifest file stays on disk. |
| No config file | manni.config.yaml is created at the git root, else the working directory. --no-config is refused. |
One collection with no manifest, manni meta relocate:
collections: - name: site paths: ["docs/**/*.md"] externalMetadata: - file: ./site.metadata.yaml keys: [owner, authors]No collections, manni meta relocate docs/ guides/intro.md:
meta: schemas: [./steward.schema.json]collections: - name: default paths: ["docs/**", "guides/intro.md"] externalMetadata: - file: ./default.metadata.yaml keys: [authors, owner]One collection and a target outside it, manni meta relocate notes/:
collections: - name: site paths: ["docs/**/*.md", "notes/**"] externalMetadata: - file: ./docs-meta.yaml keys: [owner, authors]Finding identity
Section titled “Finding identity”A rule 2 finding carries the schema ref external:owned, the keyword
external, the key as subject, /<key> as its instance path, and the
document’s line for the key:
{ "schema": "external:owned", "keyword": "external", "subject": "jira", "instancePath": "/jira", "message": "\"jira\" is owned by manifest docs-meta.yaml (collection pages); remove it from the document", "line": 3}The ref is shaped like check:<name>, so the rule id everywhere, in SARIF,
JUnit and the baseline, is the ordinary join external:owned/external. The
external segment is reserved as a first segment beside check. It is listed
with the other reserved rule ids.
A duplicate-join finding is shaped the same way. Its schema ref is
external:duplicate, its subject is the shared value, and its instance path
is /<field>, at the field’s line in each page. Its rule id is
external:duplicate/external. See Joining by a field.
A rule 6 finding is not a new rule. It is whatever the schema said, with one
extra JSON field: file
naming the manifest, which line then refers to. Its subject file stays the
document, so its fingerprint and its results[].file are unchanged by where
the value came from.
Exit codes
Section titled “Exit codes”| Exit | When |
|---|---|
2 | The config’s collections[].externalMetadata is malformed. A manifest is missing, unreadable, not valid YAML, or not the mapping shape above. A remote manifest could not be fetched, the run is --offline, or the variable tokenEnv names is not set. An entry sets a key outside keys:, or sets $schema. An entry names a document the run did not load, or a field value no document carries, on an unscoped corpus run. One file is a member of two collections whose manifests own the same key. A query write would touch an owned key, rename a path-joined document, or change the join field of a matched one. |
1 | The merged object fails its schema, including on a value the manifest supplied. A document carries an owned key. Two documents share one value of a join field. fill refuses a file because a candidate is owned. |
The split is the one every command already follows. Exit 2 means the run could not establish the contract and produced no verdict. Exit 1 means it did, and the documents still need work.
The orphan check
Section titled “The orphan check”A manifest entry whose document the run did not load is the rename case. The page moved, the entry did not, and nobody reads the entry again. That is the silent green an unread named input always produces, so it is exit 2:
$ manni meta validate -c manni.orphan.config.yamlmanni: Manifest docs-meta.orphan.yaml:3 names "docs/gone.md", which this run did not load. Fix the entry, or remove it. exit 2It runs only when the resolved file set is a corpus. A positional path,
stdin, --as, --ext, --exclude, --no-gitignore or -s means the operator
chose to look at part of one. An entry for the rest is then expected rather than
orphaned. manni meta validate docs/auth.md from the same directory passes
without mentioning docs/gone.md.
--collection is the exception, and it differs from named
checks here. Naming collections does not reshape the corpus,
it chooses one. The run loads all of every collection it names, so an entry of a
named collection is still accountable. manni meta validate --collection pages reports the orphan above. Entries belonging to collections the run did not
name are skipped, since it never looked at their documents. A job narrowed to one
collection therefore keeps the check it relies on.
Unlike checks, the orphan check has no opt-out flag. A corpus run is the one place the whole manifest is accountable, and the fix is a one-line edit.
A field-joined entry has the same check, phrased for its key, and under the same invariant. It runs after the per-file loop, because the values are only known once every document is read:
$ manni meta validate -c manni.orphan.config.yaml # in test/fixtures/external-metadata-join/manni: Manifest docs-meta.orphan.yaml:3 names id "gone-guide", which no loaded document carries. Fix the entry, or remove it. exit 2The recommended layout
Section titled “The recommended layout”This applies when the manifest is private and the pages are public. Put the public repository inside the private one as a git submodule, and run from the private checkout:
private-repo/ manni.config.yaml # collections: public/docs/**/*.md + externalMetadata, meta: overrides docs-meta.yaml schemas/private.json public/ # git submodule: the public docs repository manni.config.yaml # plain; never mentions external metadata docs/guides/auth.mdThe alternative, two sibling checkouts with
paths: ["../public/docs/**/*.md"], runs. It reports worse. SARIF locates every
finding relative to the nearest repository root, and it cannot represent a
finding in another repository’s file. From a sibling layout every document
finding is outside the private root, so every one is dropped from the SARIF log.
The
existing notice
goes to stderr. The submodule layout keeps every document URI under the private root,
so the log is complete. .gitignore filtering applies per repository root in
both layouts, so the public repository’s ignores still hold for its files.
A path that climbs out of the config’s directory is also a member of nothing (see Membership). A sibling layout therefore gets no manifests at all. That is the second reason to prefer the submodule.
additionalProperties: false in the page’s schema
Section titled “additionalProperties: false in the page’s schema”A schema that closes its object rejects every key the manifest supplies,
because after the merge the key is in the object. That is correct, and honest.
The page’s schema has to leave room for what the manifest adds. The finding
names the manifest line. With the fixture corpus pointed at a schema that is
the same as private.schema.json except for "additionalProperties": false
and none of the manifest’s keys, saved as public.schema.json, docs/auth.md
reports:
✗ docs/auth.md (root) must NOT have additional property 'source' (docs-meta.yaml:3) [./public.schema.json] (root) must NOT have additional property 'jira' (docs-meta.yaml:4) [./public.schema.json]Two fixes:
- Leave
additionalPropertiesunset in the page’s schema, which is the default and what every built-in schema except the Agent Skills pair does. - Or keep it closed, and have the
overridesentry that governs the collection restate the whole contract in its own schema. Declare the manifest’s keys there, and the closed schema is not in that run’s set at all.
Encryption key
Section titled “Encryption key”encryptionKey: is the family’s one encryption key. It sits at the top level
beside collections:, and every tool reads it. A value whose schema marks it
x-manni-encrypt
is written encrypted under this key, and manni meta validate decrypts it with
this key to check it.
encryptionKey: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08collections: - name: site paths: ["docs/src/content/docs/**/*.{md,mdx}"]| Key | Type | Required | Default | Description |
|---|---|---|---|---|
encryptionKey | string | no | none | The family key, of at least 32 hex or base64url characters. manni key set generates 64 random hex characters, which is 256 bits. MANNI_ENCRYPTION_KEY in the environment wins over it. |
- The environment wins. When
MANNI_ENCRYPTION_KEYis set, it is the key and the config’s is not read. An empty variable counts as unset, because that is what an absent CI secret expands to. - The key counts for discovery. A
manni.config.yamlcarryingencryptionKey:and nometa:section is still this tool’s config, and stops the search. A file the key prompt created holds nothing else, and the next run has to find it. - An explicit
-cfile whose top level carriesencryptionKey:is read as a family file. A legacydocmeta.config.yamlcarries no key, as it carries no collections. - Refusals are exit 2, and neither repeats the value:
manni.config.yaml: "encryptionKey" must be at least 32 hex or base64url characters. Run `manni key set` to generate one.MANNI_ENCRYPTION_KEY must be at least 32 hex or base64url characters.A malformed MANNI_ENCRYPTION_KEY is refused by the first run that needs a
key, not by every run in a repository that encrypts nothing.
Strength is the operator’s. The configured value is key material. It is
expanded, not stretched, so a hand-picked value is only as strong as it is
random. Let manni key set generate one.
When a write needs a key
Section titled “When a write needs a key”manni meta fill, and a manni meta query UPDATE or INSERT, write a
marked value encrypted. When no key is available, they offer to create one on a
terminal, where stdin and stderr are both terminals:
$ manni meta fill docs/auth.mdmanni: /owner must be encrypted, and no encryption key is available.Generate a key and write it to manni.config.yaml? [y/N] yEncryption key written to manni.config.yaml.fill asks before its first model request, so a refused prompt never wastes a
paid run. With no config file, the key goes to manni.config.yaml at the git
root, else the working directory. The confirmation then reads Created manni.config.yaml with an encryption key. An answer other than y, an empty
answer, or no terminal to ask on stops the command with exit 2:
manni: /owner must be encrypted, and no encryption key is available. Run `manni key set`, or set MANNI_ENCRYPTION_KEY.validate, get, a query SELECT, and a query --dry-run never ask.
Joining on an encrypted field
Section titled “Joining on an encrypted field”A manifest joined on a field the pages hold encrypted is matched on the decrypted value, when the key is available. With no key the run cannot match the entries, and refuses with exit 2:
externalMetadata join field "owner" is encrypted on these pages, and no encryption key is available to match them.Providers
Section titled “Providers”providers: holds the inference settings that manni meta fill and
manni docevals both read. It sits at the top level beside collections:, so
one declaration serves every tool that sends content to a model. It names the
provider and model to fall back on, and how to reach each provider.
providers: provider: openai model: <model-id> openai: baseUrl: https://llm-gateway.internal.example.com/v1 apiKeyEnv: GATEWAY_API_KEY llama-cpp: modelsDir: .models thoughtTokens: 0collections: - name: site paths: ["docs/**/*.{md,mdx}"]| Key | Type | Required | Default | Description |
|---|---|---|---|---|
providers.provider | string | no | auto | The provider a tool uses when nothing more specific names one. One of auto, anthropic, openai, claude-cli or llama-cpp, where auto detects one. An unknown name is an error (exit 2). |
providers.model | string | no | the provider’s default | The model within providers.provider, which must then name a provider other than auto (exit 2). The model goes only to that provider. |
providers.anthropic.apiKeyEnv | string | no | ANTHROPIC_API_KEY | The environment variable that holds the Anthropic key. |
providers.openai.baseUrl | string | no | https://api.openai.com/v1 | Any OpenAI-compatible endpoint, such as Ollama, vLLM or an internal gateway. |
providers.openai.apiKeyEnv | string | no | OPENAI_API_KEY | The environment variable that holds the key for that endpoint. |
providers.claude-cli.command | string | no | claude | The Claude CLI executable to run. |
providers.jev.apiKeyEnv | string | no | TYPESAFE_API_KEY | The environment variable that holds the Jev key. Jev answers decisions only, so only manni tracevals uses it, as the judge inside a hook. |
providers.jev.baseUrl | string | no | https://api.typesafe.ai | The Jev endpoint. |
providers.llama-cpp.modelsDir | string | no | ~/.hawkeyexl-inference/models | Where the local model’s weights live. A relative path resolves from the config file’s directory. With the key unset, INFERENCE_MODELS_DIR in the environment replaces the default. |
providers.llama-cpp.thoughtTokens | integer | no | 0 | Tokens the local model may spend thinking before its JSON answer, as a whole number of 0 or more. |
providers.llama-cpp.keepAlive | string | no | 10m | How long the model host keeps a local model loaded after its last request, while a session holds it. A duration such as 30m or 24h, or 0 to unload a model as soon as no session holds it. |
Every section is optional, and a section need carry only the keys you change. An unknown key at any level is an error (exit 2) that lists the keys the section takes.
Which provider runs
Section titled “Which provider runs”A tool takes its provider from the first of these that names one:
--local, which always meansllama-cpp. See Local inference.- The
--providerflag. - An
aieval’s ownprovider:key, inmanni docevalsonly. - The tool’s own key, which is
meta.fill.providerordocevals.provider. - The family’s
providers.providerkey. - Detection, as
auto.
- A model stays with its provider. Each level’s model goes only to the
provider that same level names. Say
providers:namesanthropicand a model, and--provider openaiwins. The run then takes OpenAI’s default model, never the Anthropic one. A tool key with a model and no provider lends the model to the provider in force.--modelapplies to whichever provider wins. - Connection settings always come from
providers:. The endpoint, the key variable, the command and the local settings are read from the winning provider’s section here, whichever level chose it. A tool’s own section carries none. - Detection reads them too. Under
auto, a configuredopenai.baseUrl,claude-cli.commandandllama-cppsection all reach detection. AbaseUrlalone is enough for detection to countopenaias usable. - The map counts for discovery. A
manni.config.yamlcarrying onlyproviders:is still each tool’s config, and stops the search. - Refusals are exit 2, and each names the file:
manni.config.yaml: Unknown provider "gemini". Available: anthropic, openai, claude-cli, llama-cpp, auto.manni.config.yaml: "providers.model" was given without a provider: a model name does not say which provider owns it. Set providers.provider to one of anthropic, openai, claude-cli, llama-cpp, or drop the model to take the detected provider's default.manni.config.yaml: "providers.openai" has unknown key "model". Supported keys: baseUrl, apiKeyEnv.A providers: key under docevals: is refused by name, with a pointer to this
section:
manni.config.yaml: "providers" is no longer a docevals key. Provider settings are declared once for every tool, under a top-level providers: map. See https://hawkeyexl.github.io/manni/meta/reference/configuration/#providersLimits under auto
Section titled “Limits under auto”Two settings do not steer detection. The inference library decides what detection reads, and manni passes these through unchanged.
- A custom
apiKeyEnvis not read underauto. Detection looks only forANTHROPIC_API_KEYandOPENAI_API_KEY, and the provider it finds reads that same variable. To use a key held under another name, name the provider in config or on the command line. - The weights directory does not decide detection. Detection counts
llama-cppas usable when thenode-llama-cppruntime can start, whether or not weights sit inmodelsDir. Once chosen, the local model reads and downloads its weights there.
Local inference
Section titled “Local inference”--local runs inference on this machine with llama-cpp, whatever a config or
a page asks for. It is on manni meta fill and on docevals run, generate,
fill, promote and calibrate, with one meaning in both tools.
-
It overrides rather than refuses. A provider named at any level is set aside, and each replaced choice is said once on stderr:
manni: --local: using llama-cpp instead of "anthropic" from fill.provider.manni: --local: using llama-cpp instead of "claude-cli" from providers.provider. -
A contradicting
--provideris a usage error. Beside--local, the flag may namellama-cpporauto. Any other name exits 2:manni: --local and --provider openai contradict each other: --local runs inference on this machine with llama-cpp. Drop one of them. -
claude-clinever qualifies. Its binary runs on this machine, and its inference does not. -
A configured model applies only from a level that named
llama-cpp. A--modelflag always applies. -
Detection never runs, so no key in the environment can redirect the run.
Offline draws the same boundary, and then runs only a model already
downloaded. Its note names --offline in place of --local.
What --local means for a data-egress review is on Run fill under a
data-egress policy.
Choosing a provider for judged evals is on Choose a
provider.
tools: holds the settings of an outside tool that a manni domain runs. It
sits at the top level beside collections: and encryptionKey:, because an
outside tool belongs to no one domain. Each tool gets a key of its own under
tools:, with its own keys beneath. Five tools are read: Vale, DITA Open
Toolkit, Doc Detective, Claude Code and MadCap Flare.
collections: - name: site paths: ["docs/**/*.md"]
tools: vale: config: .vale.ini dita-ot: home: tools/dita-ot doc-detective: config: .doc-detective.json claude-cli: model: sonnet maxTurns: 50 mcpConfig: .mcp.tasks.json flare: target: HTML5 home: "C:/Program Files/MadCap Software/MadCap Flare 21/Flare.app"| Key | Type | Required | Default | Description |
|---|---|---|---|---|
tools.vale.config | string | no | none | Vale’s config file, relative to manni.config.yaml, or absolute. Passed to every Vale run as --config. Absent, Vale finds its own config, as it does when a person runs it. |
tools.dita-ot.home | string | no | none | DITA Open Toolkit’s installation directory, relative to manni.config.yaml, or absolute. Absent, the dita on your PATH runs. |
tools.doc-detective.config | string | no | none | Doc Detective’s config file, relative to manni.config.yaml, or absolute. Passed to manni test procedures as Doc Detective’s --config. Absent, Doc Detective finds its own config in the working directory. |
tools.claude-cli.model | string | no | Claude Code’s default model | The model the synthetic user of manni test tasks runs on, passed to Claude Code as --model. The judge’s model is separate, and comes from --model or providers:. |
tools.claude-cli.maxTurns | integer | no | 50 | The synthetic user’s turn limit, passed as --max-turns. A run that reaches it is judged like any other, and its report says where it stopped. A positive integer. |
tools.claude-cli.mcpConfig | string | no | none | An MCP config file, relative to manni.config.yaml, or absolute. Passed as --mcp-config, and its servers are the only ones the synthetic user gets. A Playwright MCP server here gives it a browser. Absent, it gets no MCP servers. |
tools.flare.target | string | no | the project’s only target, else none | The Flare target whose conditions and variable overrides apply to every Flare topic a domain reads. With no target, every condition is included and every variable takes its default. |
tools.flare.home | string | no | none | The folder holding MadCap Flare’s madbuild.exe, relative to manni.config.yaml, or absolute. |
- Who reads it.
manni term lintandmanni term write -f valestart Vale withtools.vale.config.manni lintstarts DITA-OT fromtools.dita-ot.home.manni test proceduresstarts Doc Detective withtools.doc-detective.config.manni test tasksstarts Claude Code withtools.claude-cli.manni lintandmanni graphread a MadCap Flare topic astools.flare.targetbuilds it. The Flare guide shows how.manni metastarts no outside tool, and never uses a value. - The key counts for discovery. A
manni.config.yamlcarryingtools:and nometa:section is still this tool’s config, and stops the search. - Every tool checks the shape. A misspelled tool or key stops any command with exit 2. A setting nothing reads would otherwise look configured and do nothing:
manni: manni.config.yaml: tools has unknown key "prettier". Supported keys: vale, dita-ot, doc-detective, claude-cli, flare.manni: manni.config.yaml: tools.vale has unknown key "ini". Supported keys: config.manni: manni.config.yaml: tools.vale.config must be a non-empty string.manni: manni.config.yaml: tools.claude-cli has unknown key "turns". Supported keys: model, maxTurns, mcpConfig.manni: manni.config.yaml: tools.claude-cli.maxTurns must be a positive integer.manni: manni.config.yaml: tools.flare has unknown key "targets". Supported keys: target, home.manni: manni.config.yaml: tools.flare.target must be a non-empty string.- A config file must exist when its tool runs. A path to a missing file is refused by the command that would start the tool, not by every run:
manni: manni.config.yaml: tools.vale.config "nowhere.ini" does not exist.manni: manni.config.yaml: tools.doc-detective.config "nowhere.json" does not exist.manni: manni.config.yaml: tools.claude-cli.mcpConfig "nowhere.json" does not exist.- A Flare target must exist. A
tools.flare.targetthat names no target in the project stops the run that reads a topic, with exit 2:
manni: tools.flare.target "PDF" names no target in Widget.flprj. Its targets are HTML5 and Print.These are the keys of the meta: section: what the metadata validator does with
the documents collections: declares. Nothing here decides which
files a run reads.
| Key | Type | Default | Description |
|---|---|---|---|
schemas | (string | {ref, source?, integrity?})[] | none | Schemas for every file that no higher-precedence rule matches. They join the default set, after it, unless defaults is false. See Schemas. |
defaults | boolean | true | For files no override matches. true puts the default set first, then schemas. false makes schemas replace the default set. See Defaults and strict. |
strict | boolean | false | For files no override matches. true stacks the strict version of each default and each registered schema in the set, right after its base. See Defaults and strict. |
register | string[] | none | Paths to local JSON schema files or directories. Each schema is loaded once and named by its $id, which then works anywhere a built-in id does. See Register. |
overrides | Override[] | none | Per-collection or per-glob schema overrides. The first matching entry wins. See Overrides. |
checks | Check[] | none | Named cross-file rules, written as SQL over the corpus’s metadata table, that validate runs after the per-file schemas, with rows reported as findings. See Checks. |
derive | Derive | none | The managed fields. manni meta derive stamps them from git, CODEOWNERS, the GitHub or GitLab review history, or a command the config names. validate compares each stamp against that evidence, and one that disagrees is a finding. See Derive. |
baseline | string | none | Path to a validation baseline. Setting it turns on --baseline for every run; --no-baseline suppresses it for one. A bare --write-baseline records into this same file. |
allowEmpty | boolean | false | Treat an input set that resolves to zero files as success instead of an error (exit 2). Equivalent to --allow-empty. See empty input sets. |
respectGitignore | boolean | true | Skip files .gitignore covers when expanding directories and globs. --no-gitignore overrides it for one run. See .gitignore. |
offline | boolean | false | No network egress. Never fetch a remote schema, and fill runs only a local model already downloaded. --offline turns it on for one run. See Offline. |
schemaCache | SchemaCache | none | Settings for the cross-run schema cache. See Schema cache. |
schemaTrust | SchemaTrust | none | How far a document’s own $schema is trusted. Absent means any, which is every reference kind, as manni meta has always behaved. See Schema trust. |
fill | Fill | none | Defaults for manni meta fill. See Fill. |
elements | string[] | none | Extra element paths to lift, beyond each format’s convention. They accumulate with every matching overrides: entry. See elements. |
The section must be a YAML mapping. schemas takes two
forms. allowEmpty and respectGitignore must be booleans. A quoted "false" is a string, and accepting it would turn a
setting on for someone who wrote it off.
defaults and strict must be booleans too, for the same reason:
manni: manni.config.yaml: meta.defaults must be true or false.manni: manni.config.yaml: meta.strict must be true or false.Unknown keys are errors
Section titled “Unknown keys are errors”Any key outside the table above is an operational error (exit 2), naming the
key it did not recognize and listing the ones it accepts. The same rule applies
inside every nested mapping: schemas[] entries, checks[] entries,
schemaCache, schemaTrust, fill, and derive. It applies to
collections[] and its externalMetadata[] entries too, which
are checked by the shared parser before any tool sees them.
This is what makes a typo visible. A dropped schemaTust: leaves a repository
that reads as guarded and is not. A dropped intergrity: leaves a schema that
reads as pinned and is not. Neither produces a diagnostic at any verbosity, so
the config file itself is the only evidence, and it looks correct.
Schemas
Section titled “Schemas”Each entry in schemas: is either a plain reference string or a mapping. Both
forms can appear in the same list.
meta: schemas: - google:okf:0.1 # string form - ref: ./schema/2.1.json # mapping form source: https://schemas.example.com/house/2.1.json integrity: sha256-9f8e7d6c…| Field | Type | Required | Description |
|---|---|---|---|
ref | string | yes | What is loaded, whether a built-in id, a local .json path, a local Flare meta tag set, or a URL. Identical in meaning to the string form. |
source | string | no | Where ref came from. Recorded by schemas vendor so a re-vendor knows the URL, and so an error about the file can name it. |
integrity | string | no | sha256-<64 hex characters> over the file’s bytes. A mismatch is an operational error (exit 2). |
The two forms mean exactly the same thing to schema resolution. The ref string
is what appears in reports, in baseline
fingerprints, and in json output’s
schemas array. source and integrity change nothing about which schema is
used. They change what happens when the file on disk is not the one that was
recorded.
Rules, all of them exit 2 when broken:
- An unknown key in the mapping is an error rather than being ignored. A typo
such as
intergrity:would otherwise leave a config that reads as pinned and is not. integritymust matchsha256-<64 hex characters>. There is one algorithm and one encoding; write them withschemas vendorrather than by hand.integrityis only accepted on a local file reference. A built-in has no bytes to check. A URL may be served from the schema cache, which stores the parsed schema rather than what the server sent. So a pin on either could never be verified.
overrides[].schemas takes the string form only. Pins live in one place, and an
override that matched a pinned ref still gets that pin, because pins are keyed
on the reference.
Defaults and strict
Section titled “Defaults and strict”defaults and strict shape the set for every file no override matches.
An override entry takes the same two keys for its own files. See
Overrides.
| Key | Type | Default | What it does |
|---|---|---|---|
defaults | boolean | true | true puts the default set first, then schemas. false makes schemas replace the default set. |
strict | boolean | false | true stacks a strict version right after each schema in the set that is a default or a registered schema, when one exists. |
A config that adds one house schema to the defaults names only that schema:
meta: schemas: - ./schemas/house.jsonEach file then meets the eleven defaults and ./schemas/house.json, in that
order. A duplicate keeps its first place, so listing a default again changes
nothing. Listing another version of a default replaces it, so
manni:stewardship:1.0.0 takes the place of manni:stewardship:1.2.0.
To judge files by the listed schemas alone, turn the defaults off:
meta: defaults: false schemas: - ./schemas/house.jsonWith strict: true, each manni: default gets its overlay after it, such as
manni:core-strict:1.0.0 after manni:core:1.0.0. A listed version of a
default vocabulary gets its own overlay, such as
manni:stewardship-strict:1.0.0 after manni:stewardship:1.0.0. A registered
vendor:name-strict:version follows its registered vendor:name:version the
same way. A listed built-in outside the default set stays as listed, so
tgdp:templates:1.1 stays open. List tgdp:templates-strict:1.1 to close it.
meta: strict: true schemas: - ./schemas/house.jsondefaults: false with no schemas would leave files with no schema at all.
It is refused, exit 2:
manni: manni.config.yaml: meta.defaults: false with no meta.schemas leaves files with no schema. List schemas, or remove defaults.Register
Section titled “Register”register names local JSON schemas by path, relative to the config file. A
path is a schema file, or a directory whose *.json files all register,
however deep. Each schema is loaded once and named by its own $id.
Inside a registered directory, a symlink to a schema file registers. A symlink to a directory is not followed, so a link cycle cannot loop the walk. It is named on stderr instead:
manni: meta.register: schemas/linked is a symlink to a directory, which is not followed.meta: register: - ./schemas/ strict: true schemas: - house:page:1.0.0{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "house:page:1.0.0", "type": "object", "required": ["team"]}A registered $id is vendor:name:version, as in house:page:1.0.0, or an
absolute https:// URL. It works anywhere a built-in id works:
- in
schemasandoverrides[].schemas; - in
-s/--schema; - in a document’s
$schema, where it counts as a built-in under Schema trust, because the config vouches for the file; - in a
$reffrom any schema.
A registered URL id answers from its file, so no fetch is made. A registered
vendor:name-strict:version is the strict version of a registered
vendor:name:version, which strict: true stacks right after it. A URL id
has no strict pair, so strict: true stacks nothing after one. List its strict
version by hand instead. Nothing
registers under --no-config, so a registered id is then an unknown schema.
manni meta schemas lists every
registered id beside its file.
Each of these stops the run before any file is validated, exit 2:
| Situation | Message |
|---|---|
| A path that does not exist | meta.register[<i>] names <path>, which does not exist. |
| A path that is neither a file nor a directory, such as a pipe | meta.register[<i>] names <path>, which is not a file or a directory. |
| A directory with nothing to register | meta.register[<i>] names a directory with no .json files. |
| A file that does not parse | meta.register: <file> is not valid JSON. |
A schema with no $id | meta.register: <file> has no $id. A registered schema is named by its $id. |
An $id of another shape | meta.register: <file>'s $id "<id>" is neither vendor:name:version nor an https URL. |
Two files with one $id | meta.register: <file> and <file> both register "<id>". |
An $id a built-in already uses | meta.register: <file> registers "<id>", which is a built-in id. |
| A reserved vendor | meta.register: <file> registers "<id>" under the reserved "<vendor>" vendor. |
The reserved vendors are manni, check, external, encrypted and
derived. The family’s own ids and findings use them. Each message carries
the usual manni: <config path>: prefix.
respectGitignore
Section titled “respectGitignore”Set it to false for a repository whose ignored files should still be checked,
a vendored docset, or a generated tree you deliberately validate:
meta: respectGitignore: falseSetting it to true says the same thing as the default, with one addition. It
records that you asked. A run where git could not answer then prints one line
to stderr, instead of silently checking everything. That is a run with no
repository, or no git on PATH. Worth doing in a CI image you do not control.
Offline
Section titled “Offline”offline: true stops manni meta fetching a schema over the network at all. A url
reference is served from the schema cache; one that is not
cached is an operational error (exit 2) naming the URL. A remote
manifest is refused outright, because a manifest is never
cached. Built-in ids and local .json files are unaffected, because neither
touches the network. An air-gapped build against the default schema set works
with nothing else configured.
fill sends nothing off this machine either. It runs llama-cpp as under
--local, and only with a model already downloaded. A
hosted provider in config is set aside with a note. A hosted --provider exits
2:
manni: --offline and --provider openai contradict each other: --offline sends nothing off this machine, so only llama-cpp with a model already downloaded can run. Drop one of them.Nothing is downloaded or installed. With no model on disk, fill exits 2:
manni: no model is available offline, so fill cannot propose values; download one first with --local, or drop --offline.meta: offline: true--offline sets it for a single run. There is no flag to turn it back off, so
leave it out of config unless the whole project is meant to run that way.
Schema cache
Section titled “Schema cache”schemaCache is a mapping of settings for the cross-run
cache of schemas
fetched over http(s).
| Key | Type | Default | Description |
|---|---|---|---|
ttlHours | number | 24 | Hours a cached schema is served before it is re-fetched. 0 disables the cache entirely, in both directions; the maximum is 8760 (one year). |
meta: schemaCache: ttlHours: 1ttlHours must be a number between 0 and 8760 (one year). A negative value, a
non-number, YAML’s 1e999, or anything past the upper bound is an error (exit
2). Both bounds are rejected rather than clamped, and for symmetric reasons. A
negative TTL makes every entry read as stale. A value large enough to overflow
the internal millisecond arithmetic makes no entry ever read as stale. Each
gives a cache that silently stops doing its job, in opposite directions. That is
the failure this key exists to prevent.
Schema trust
Section titled “Schema trust”schemaTrust decides how far a document is trusted to choose the schema it
is judged against. A file’s own $schema sits above config in the precedence
chain. That is the feature
which makes a document self-describing. In a repository that takes outside pull
requests, it is also the thing a contributor can use to opt out of the standard.
| Key | Type | Default | Description |
|---|---|---|---|
documentRefs | "any" | "local" | "none" | any | What a document’s $schema may name. |
hosts | string[] | none | Hosts a document-supplied URL may name. Consulted only under documentRefs: any; absent means any host. |
meta: schemaTrust: documentRefs: localdocumentRefs | A document’s $schema may name | A document’s $schema may not |
|---|---|---|
any (default) | A built-in id, a registered id, a file inside the repository, or a URL, narrowed by hosts when that key is present. | A path outside the repository. |
local | A built-in id, a registered id, or a file inside the repository. | Any URL. |
none | Nothing, because the key is ignored and the config decides. manni meta prints one line to stderr naming the file and the reference it dropped. | n/a |
This key never applies to a reference an operator supplied. schemas,
overrides[].schemas, and -s/--schema are unfiltered in every mode. Someone
who can edit the config or pass a flag is not the person this key has in mind.
A refused document is one failing file (exit 1), not a stopped run. The
refusal is reported as that file’s error, so in github and sarif output the
annotation lands on the offending document in the pull request. Every other file
in the run is validated as usual.
A built-in id keeps working under local, which is what makes that mode safe to
turn on. The self-describing
document
pattern is $schema: google:okf:0.1 in the frontmatter. It names a schema
shipped inside manni meta, so it reaches nothing.
Where “inside the repository” ends
Section titled “Where “inside the repository” ends”A document-supplied path must resolve inside the repository, in every mode
that honors the reference, any included, because containment costs no existing
setup anything. $schema: ../../../../etc/passwd is refused; a monorepo
package’s $schema: ../shared/house.json is not, because the boundary is the
git root rather than the config’s directory.
With no git repository anywhere above the run, the boundary falls back to the config’s own directory, and the message says so. “Outside the repository” and “outside where you happen to be standing” stay distinguishable.
Paths in schemas and on --schema are never contained. A schema kept beside
the project rather than in it is an ordinary setup, and an operator wrote it.
fill is a mapping of defaults for the fill
command. Every key is optional, and every one is overridden by its CLI flag.
| Key | Type | Default | Description |
|---|---|---|---|
provider | string | providers.provider, else auto | Inference provider: auto, anthropic, openai, claude-cli, or llama-cpp. It overrides the family’s providers.provider for fill. auto detects one. An unknown name is an error (exit 2). |
model | string | provider default | Model override, which replaces providers.model for fill. It needs a provider other than auto, from provider, --provider or providers.provider. A model name does not say which provider owns it (exit 2). |
confidenceThreshold | number | 0.7 | Minimum self-reported confidence, from 0 to 1, needed to write a value. |
maxTurns | number | none | Stop after this many inference calls. Counts calls, not files: a long document is split across several. |
chunkChars | number | 12000 | Characters of document sent per call. The whole file is always sent; this decides how many calls that takes. |
concurrency | number | 4 | Files inferred in parallel. Between 1 and 64. |
A numeric key outside its range, or one that is not a finite number such as
YAML’s 1e999, is an error (exit 2).
meta: fill: provider: anthropic confidenceThreshold: 0.9 maxTurns: 200Connection settings are not fill keys. An OpenAI-compatible baseUrl, a
key’s environment variable or a weights directory goes in
providers:, where manni docevals reads it too. A model named
here goes only to the provider named here, as Which provider
runs explains.
Pinning provider is worth doing in CI, here or in providers.provider. Left
on auto, a runner that loses its API key silently falls through to a local
model and downloads gigabytes rather than failing the build.
elements
Section titled “elements”Element paths to lift in addition to each format’s convention. Slash-separated
and absolute from the document root, optionally ending in @attribute:
meta: elements: - article/byline/author # → byline.author - html/head/link@href # → head.linkThe key is derived from the path’s last two segments and is never spelled here. Paths are validated when the config loads, so a typo is an error naming the file rather than a check that silently never runs.
Unlike schemas:, these accumulate. The top-level list and every matching
overrides: entry all contribute. Element paths are extra places to look,
rather than a complete statement about how a file is judged. See Element
metadata.
Overrides
Section titled “Overrides”overrides is a list of mappings, each applying a schema set to a
collection or to files matching a glob.
| Field | Type | Required | Description |
|---|---|---|---|
collection | string | one of the two | The name of a declared collection. The entry matches that collection’s members. |
files | string | string[] | one of the two | A glob, or a list of globs, matched against each file’s path. |
schemas | string[] | see below | The schema set applied to matching files. |
defaults | boolean | no, default false | true puts the default set first, then the entry’s schemas. false keeps the entry’s schemas alone. |
strict | boolean | no, default false | The same as strict, for the entry’s set. |
elements | string[] | no | Extra element paths to lift for matching files. These accumulate rather than replacing. |
An entry must set schemas, elements, or both. One that sets neither is
refused, because it is a rule that does nothing and reads as configured:
manni.config.yaml: meta.overrides[0] sets neither "schemas" nor "elements", so it has no effect.
Each entry carries exactly one of collection and files. Both would need a
rule for how they combine, and neither governs nothing:
manni.config.yaml: meta.overrides[0] must carry exactly one of "files" or "collection".A collection that names no declared collection is refused too, once both halves
of the family file are known:
manni.config.yaml: meta.overrides[0].collection names "gides", which collections: does not define. Defined: guides, blog.For a given file, manni meta uses the first override that matches it (in list
order); later matching overrides are ignored. That holds across the two kinds:
a collection: entry and a files: entry compete in one ordered list. See
schema resolution for where
overrides sit in the precedence chain.
collections: - name: guides paths: ["docs/guides/**/*.md"] - name: api paths: ["docs/api/**/*.md"]
meta: overrides: - collection: api schemas: [./schemas/api.json] - collection: guides schemas: [./schemas/guide.json]Point an override at a collection whenever the collection is the thing you meant.
The config then declares the set once, and the override says what judges it. Keep
files: for a shape no other tool needs to name, such as a single file or a path
pattern that cuts across collections.
Defaults and strict in an override
Section titled “Defaults and strict in an override”An override’s schemas replace the set by default. An entry never
inherits the top-level schemas, and it gets the default set only when it
asks for it with defaults: true. Its own strict then applies to the
entry’s set:
meta: overrides: - collection: guides defaults: true strict: true schemas: - ./schemas/guide.json - tgdp:templates:1.1A guide then meets the eleven defaults, each manni: overlay right after
its base, then ./schemas/guide.json and tgdp:templates:1.1. TGDP stays
open, because it is neither a default nor registered.
defaults and strict apply to the entry’s schemas. An entry that sets
either without schemas is refused, exit 2, as is a value that is not a
boolean:
manni.config.yaml: meta.overrides[0] sets strict, which applies to the entry's schemas. Add schemas, or remove strict.manni.config.yaml: meta.overrides[0] sets defaults and strict, which apply to the entry's schemas. Add schemas, or remove them.manni.config.yaml: meta.overrides[0].defaults must be true or false.manni.config.yaml: meta.overrides[0].strict must be true or false.Grouping several globs
Section titled “Grouping several globs”files accepts a list, and a file matches when any glob in it does. Use it
when one schema set governs path shapes that no single pattern reaches. Brace
expansion needs a common stem, and these have none:
meta: overrides: - files: - ".claude/skills/*/SKILL.md" - ".claude/agents/*.md" schemas: [agentskills:skill:1.0]A list counts as one override for first-match-wins, so the whole group has a
single position in the precedence order. An empty list is refused: it would match
nothing and read as configured. A group of globs a second tool also needs to name
is a collection with two paths entries instead.
Checks
Section titled “Checks”checks is a list of named cross-file rules. Each is one SQL statement run over
the same docs projection
manni meta query builds, with one row
per file and one column per top-level metadata key. Every row the statement
returns is reported as a finding, exactly as a schema violation is. It
renders in every output format,
counts toward exit 1, and rides the baseline
ratchet.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | The check’s identity. Must match [a-z0-9][a-z0-9._-]* and not end in .json. Two checks may not share a name. query and derive are both reserved. query --check files its ad-hoc findings under check:query, and a check reporting as check:derive would read as that verb’s findings. |
query | string | yes | One SQL SELECT following the column convention below. Checks are read-only. The projection runs under query_only, so a mutating statement is refused with the check named (edits belong to manni meta query). ATTACH/VACUUM are refused by name. Checks bind no parameters, so a named parameter ($x, :x, @x) is refused too. An unbound name would bind NULL and green the gate on a typo, so inline the value. |
checks: - name: unique-slugs query: >- SELECT _path AS path, 'slug' AS key, 'duplicate slug "' || slug || '"' AS message, lineFor(_path, 'slug') AS line FROM docs WHERE slug IN (SELECT slug FROM docs GROUP BY slug HAVING count(*) > 1)Findings carry the schema ref check:<name> and the keyword check, so the
rule id everywhere, in SARIF, JUnit and the baseline, is check:<name>/check.
That is why the name grammar is enforced at parse time rather than hoped for.
The name is fingerprint identity, and a name that classified as a file path
would produce fingerprints that change with the working directory.
The column convention
Section titled “The column convention”A check’s SELECT says where each violation lives by naming its columns:
| Column | Required | Meaning |
|---|---|---|
path | required | The file the finding attaches to, which must be a file the run loaded. A row naming any other path is an error (exit 2) with the check named, as is a check whose SQL does not prepare. |
line | optional | 1-based source line. lineFor(path, key), a SQL function registered for the run, computes it from the extractor’s own position data. It returns NULL for an unknown path or a key the extractor cannot place. |
key | optional | The metadata field at fault. Becomes the finding’s instance path (/<key>), and with it part of the finding’s baseline identity. |
message | optional | The prose. When absent it is synthesized as col=value pairs from any remaining columns. |
An aggregate rule reports by emitting one row per offending file. In the example
above, the aggregate lives in the subquery, and the outer SELECT fans it back
out to files. A check that can fire more than once per file should make the
occurrences distinct through key. The baseline dedupes fingerprints as a set.
Two findings in one file that differ only in message collapse to one recorded
entry, and any number of future duplicates stay forgiven.
When checks run
Section titled “When checks run”A corpus rule computed over half a corpus reports wrong answers, such as
“dangling author” because authors/ was not loaded. So named checks run only
when the resolved file set is the config-resolved corpus. Any CLI reshaping
of the input set disqualifies the run: positional paths, stdin, --as, --ext,
--exclude, --no-gitignore, and
--collection. So does
-s/--schema, even though the file set is unchanged. A schema override reshapes
the corpus contract: it outranks every override, so no file is judged by the set
the config assigned it. A scoped run prints one stderr notice and validates as
usual. --collection is named in that notice, because “scoped” alone reads as a
mystery when the only flag on the command line selects a collection:
$ manni meta validate --collection guides --collection blogmanni: corpus checks skipped: run is scoped to collections guides, blog--collection disqualifies a run even though every declared collection still
gets a view. The views of the
unselected collections would hold only what the run happened to load. A
FROM blog check would pass by having nothing to fail on. Config-level
respectGitignore: does not disqualify, because it defines the corpus; the
CLI flags redefine the run. A collection’s own exclude: does not disqualify
either. It is a membership rule, not a run-shaping one.
--no-checks opts out explicitly for one
run, mirroring --no-baseline. Checks resolve no schemas and make no network
requests, so --offline does not affect them.
Derive
Section titled “Derive”derive names the managed fields. A managed field is one whose value is
computed from evidence rather than typed by a person. The evidence is git
history, a CODEOWNERS file, and the pull request or merge request history
GitHub or GitLab keeps, read through the gh or glab CLI. For any other
field, it is the output of a command the config names.
manni meta derive stamps the computed value into the document. validate
compares what is stamped against what the evidence says, and a stamp that
disagrees is a finding.
A derived value is never merged into what the schema sees. That is the
difference from external metadata. The document stays
the truth for validation, and the evidence is what the document is held to.
Only derive writes a managed field. query refuses an UPDATE of one, and
fill skips it.
A read derives, and says which value it gave back. get prints the
resolved value of each field, annotated with its origin. A resolved value
is the asserted one where the document has the key, and the derived one where
it does not. query
builds a resolved table
that says the same thing in SQL. Neither has to be asked. --no-derived on
get, and simply not naming the table in query, are the ways to read the
document alone. A field no source can state consults nothing either way, so
reading title spawns no process.
Stamp stewardship fields from evidence is the journey. This section is the contract.
| Field | Type | Required | Description |
|---|---|---|---|
fields | string[] | no, but a derive: must set one of its keys | The managed fields. Unique when present, and each one of the derivable fields or a key with an entry in commands. Each violation is a config error (exit 2) naming derive.fields. A key a collection’s local external-metadata manifest owns is compared and stamped in that manifest. A key a URL manifest owns is refused (exit 2), because a fetched file cannot be written. See a field a manifest owns. Absent means the merge-safe fields, owner, created, last-updated and provenance. Each is managed only on a page whose schemas claim it as a top-level property. [] manages nothing, and validate compares nothing. sources, codeowners, commands or machines still shape the reads. |
sources | string[] | no | Which of git, codeowners, github, gitlab and command to consult. Non-empty and unique. The default is all five. Only the one of github and gitlab that matches the origin remote is consulted; see the review sources. A field whose only sources are excluded derives null, and can never be stale. |
codeowners | string | no | Path to the CODEOWNERS file, relative to the config file’s directory. The default is GitHub’s or GitLab’s own search order, below. When codeowners is an active source, the file must exist (exit 2). |
commands | map | no | One entry per field a program derives, keyed by the frontmatter key. Each entry has run, an argv list, and an optional timeout in seconds. The key may not be one of the seven built-in fields, a key a URL manifest owns, or $schema (exit 2). See Commands. |
machines | string[] | no | Trailer identities that are machines. Each entry is a glob matched against an identity’s name and against its email. A Co-authored-by: trailer that matches names a machine for provenance. Any identity that matches, author or co-author, is left out of authors. Globs match case-sensitively, and brackets are literal, so *[bot] means a name ending in [bot]. Non-empty when present, unique, and no entry blank (exit 2). The default is ["*[bot]"]. Prefer exact addresses, because *noreply* also matches the GitHub no-reply addresses people commit with. |
collections | string[] | no | The collections whose members derive manages. A file in none of them is neither stamped nor compared. Absent means every validated file. A name no collection declares is refused, exit 2: meta.derive.collections names "<name>", which no collection declares. |
collections: - name: pages paths: ["docs/**/*.md"]
meta: derive: fields: [created, last-updated, authors, owner, verified-against] # managed; absent: the merge-safe fields sources: [git, codeowners, github, gitlab, command] # optional; default all five codeowners: .github/CODEOWNERS # optional; default: GitHub's or GitLab's search order commands: # optional; a program per field verified-against: run: ["jq", "-r", ".version", "package.json"] machines: ["*[bot]", "noreply@anthropic.com"] # optional; default ["*[bot]"]With no fields, a page manages the merge-safe fields its schemas claim.
The default set
carries manni:stewardship:1.2.0, which claims owner, created and
last-updated, and manni:ai-context:1.2.0, which claims provenance. A
page on the defaults manages all four. A page judged by a house schema alone,
which claims none of them, manages nothing.
collections keeps derive on the pages it was set up for. Test fixtures or
drafts validated from the same root then carry no stamps and draw no stale
findings.
collections: - name: site paths: ["docs/**/*.md"]
meta: derive: collections: [site] # only the site's pages are stamped and compared sources: [git, codeowners] # no fields: the merge-safe fields each page claimsderive: absent is exactly the behaviour every config had before the key
existed. No command spawns git, gh, glab or a configured program, and
no field is managed.
A field a manifest owns
Section titled “A field a manifest owns”A managed field that a collection’s externalMetadata
manifest owns is kept in that manifest. derive compares the manifest’s value,
writes the page’s entry there with a comment-preserving splice, and never
touches the page. derive --check and validate point a stale finding at the
manifest’s line. A commands key works the same way.
A URL manifest is fetched, so it cannot be written. A derive.fields entry or
a commands key that a URL manifest owns is refused when the config loads
(exit 2):
manni.config.yaml: meta.derive.fields[3] "owner" is owned by manifest https://example.com/owners.yaml, which is fetched and cannot be written; set it in that repository.derive --fields refuses such a name
the same way for one run, and a commands key names
meta.derive.commands.<key> in place of the field’s index.
provenance was the first field kept this way. A pin is minted by the tool,
never curated by hand, so a manifest that owns provenance is where derive
stores the record.
Three manifests cannot hold the record, and each is exit 2 when derive runs:
| Situation | Message |
|---|---|
The manifest that owns provenance is a URL. derive writes the record, and a URL is not somewhere to write. | collection site: provenance cannot come from a URL manifest, because manni meta derive writes it. |
A page belongs to two collections whose manifests both own provenance. | docs/limits.md is in collections site and limits, and both keep provenance in a manifest. |
| The manifest joins on a field, and the page does not carry it. | docs/limits.md carries no id, which private/by-id.yaml joins on, so its provenance has no entry there. |
validate and get only read the record, so a URL manifest is not refused
there. Its copy at a past commit is simply not read as evidence.
The derivable fields
Section titled “The derivable fields”Six fields of the stewardship
vocabulary have a built-in source, and so
does provenance from the ai-context
vocabulary. The
other four, stakeholders, review-interval, verified-against and
source-of-truth, are judgments a person makes, and no built-in reads them.
Naming one of them in fields is a config error unless a
command derives it. That is how verified-against is checked
against the product’s own version file.
| Field | Source | Derived value | Evidence string |
|---|---|---|---|
created | git | The stamp set by the commit that added the path (rename-followed), if it set one. It is read from the page at that commit, and from the manifest that owns created there. Otherwise that commit’s author date as a UTC day, YYYY-MM-DD | added in 7a0d424 (2026-08-26) / stamped in 7a0d424 |
last-updated | git | The stamp the newest body-changing commit carries, read from the page and from the manifest that owns last-updated. It counts only if the commit changed it; else the commit’s author date as a UTC day. With an uncommitted body change: the working value if it differs from HEAD’s, else today in UTC (the one place derive reads a clock) | body changed in 424f71a (2026-09-07) / stamped in 424f71a / uncommitted body change |
authors | git | Names of authors of body-changing commits plus their Co-authored-by trailers, oldest first, deduplicated by email. An identity machines matches is excluded, which by default is a name ending [bot] | 4 body-changing commits |
owner | codeowners | Owners matching the path, @ kept. GitHub: last matching line wins, first file found of .github/CODEOWNERS, CODEOWNERS, docs/CODEOWNERS. GitLab adds .gitlab/CODEOWNERS and sections: every section applies, last match wins per section, union of owners | .github/CODEOWNERS:12 |
reviewed-by | github or gitlab, then git | Logins with an APPROVED review on the merged PR/MR containing the newest body-changing commit, [bot] suffix stripped for dedupe. Fallback: Reviewed-by: trailers on that commit | github PR #18 / Reviewed-by trailer in 424f71a |
last-reviewed | github or gitlab, then git | Date of the latest APPROVED review on that PR/MR. Trailer fallback: the commit’s author date as a UTC day | as above |
provenance | git | A list of pins, one per range of body lines one machine wrote: generated-by, lines in body lines, and integrity. A range never starts or ends on a blank line. Read from git blame and commit evidence, per line; see Provenance | uncommitted / blame 9b0e2c1 / blame 9b0e2c1, 2 commits |
The review sources
Section titled “The review sources”github and gitlab are two sources, and each applies to the platform it
names and to nothing else. github reaches GitHub through gh, gitlab
reaches GitLab through glab, and neither ever stands in for the other. The
origin remote decides which one a repository is. A host whose name says
github is GitHub, whatever the domain, and one that says gitlab is
GitLab. A host that says neither, such as git.example.com, is not guessed.
The config names it, by listing exactly one of the two in sources.
Three configurations cannot be answered and are exit 2 when reviewed-by or
last-reviewed is managed. Naming only the one the origin does not match
says the origin remote is github.com, which is GitHub; add github to sources, or drop reviewed-by and last-reviewed from the managed fields. A
bare host with both or neither listed says the origin remote is git.example.com, which names neither GitHub nor GitLab; list exactly one of github, gitlab in derive.sources to say which it is. A checkout with no
origin remote says no origin remote to tell GitHub from GitLab.
manni never holds a token. The CLI’s own login is what reaches the API, which
is why an unauthenticated CLI is an error rather than a guess. A merged pull
request’s answer never changes, so it is cached under
.manni/meta/review-cache/, keyed by host, owner, repository and commit. An
open pull request’s answer is never cached, because the next approval changes
it. --no-cache bypasses the cache for one run.
Provenance
Section titled “Provenance”provenance records which body lines a machine wrote. Each entry is a pin, as
a citation’s claim end is. lines is
where the pin was last seen, counted in body lines from the first line after
the metadata block. integrity is a sha256- hash of those lines, and the
entry’s identity. See which lines a machine
wrote is the journey.
provenance: - generated-by: claude-fable-5 lines: 6-8 integrity: sha256-537bc424c34e7cbbd04cc73c49df009628e35a3ab292f6da30ad1f071bfe8b62Blame runs only when provenance is managed. It reads the history of every
line, which costs more than the per-file log the other git fields read.
Nothing about it is cached. Each body line takes the first evidence that
answers:
- Uncommitted, with a name. The line is not committed, and
--generated-byorMANNI_GENERATED_BYnames a machine. The line is that machine’s. A range on the command line names committed lines too. - A stamp in the commit that wrote it. That commit’s copy of the page carries an entry covering the line, and the entry’s integrity matches that copy. Where a manifest holds the record, the manifest’s copy is read. The entry’s machine wrote it. A squash merge keeps this evidence, because the squash commit carries the stamp.
- A
Generated-by:trailer on that commit names the machine. - A
Co-authored-by:trailer on that commit matchesmachines. The machine is the trailer’s name as written, or its email when the name is empty. - Otherwise, no evidence.
A commit that carries a verified stamp is the whole account of the lines it
last touched. A line outside every stamped range then has no evidence, and
rules 3 and 4 do not reach it. So a Co-authored-by: trailer a squash
appends cannot make a line the branch left unstamped a machine’s.
Trailer keys compare without case. Among several trailers of one kind, the first that names a machine wins.
Lines one machine wrote in a row form one entry. A blank line, empty or whitespace only, carries no authorship. It never starts or ends an entry, and blank lines between two of one machine’s lines do not split its entry. The entry’s integrity covers them.
Each stamped entry is then matched with what the evidence derives, by integrity first:
| Status | When | Finding | What derive does |
|---|---|---|---|
current | The pin matches at its recorded lines, and no evidence names another machine | none | keeps it |
moved | The pin matches at other lines | none | rewrites lines |
changed | The pinned text is found nowhere in the body | derived:stale | re-derives the range from evidence, and drops it when none remains |
stale | The evidence names a different machine for the pinned lines, or the pinned lines start or end on a blank line | derived:stale | re-derives the range, or trims its blank lines and drops it when only blank lines remain |
unset | The evidence names a machine for non-blank lines no entry covers | derived:stale | adds an entry |
An entry derive writes replaces every kept entry that lies inside it. A
kept entry it only partly overlaps keeps its lines outside it. It splits in
two when the new entry sits in its middle, and each piece is pinned again.
No evidence is not a contradiction. A pin over lines whose commit names no
machine stands for as long as its text matches. That is how a person can
attribute lines after the fact. When two ranges hold identical text, the nearest lines value
takes the match.
The findings read, in file lines:
provenance lines 16-18 changed since claude-fable-5 wrote them — run manni meta deriveprovenance lines 29 say claude-sonnet-5; blame says Claude Opus 5 (6683e73) — run manni meta deriveprovenance lines 31-34 say claude-fable-5 but start on a blank line — run manni meta deriveprovenance is unset for lines 26; blame says Claude Opus 5 (ecb9b0a) — run manni meta deriveAn entry of blank lines reads but hold only blank lines. The other blank-line
forms read but end on a blank line and but start and end on blank lines.
--generated-by with a range, <path>:L1-L2, attributes those file lines
whether or not they are committed. It is refused when evidence rules 2 to 4
name a different machine for any of them. Several ranges of one file each
attribute their lines. Blank lines at either end of a range are left out,
and a range of only blank lines writes nothing. A range must name one file
the run reads, never a directory or a glob. Without a range it attributes
uncommitted lines only. Every refusal is listed in exit 2 from a derive
source.
Commands
Section titled “Commands”commands names a program per field, for a field no built-in source
claims. The trimmed stdout is the derived value, and the field is then
managed exactly as the seven built-ins are. derive stamps it, validate
reports a stamp that disagrees, fill and query refuse to write it, and
get --derived and the derived table
show it with the argv as evidence.
meta: derive: fields: [last-updated, verified-against] sources: [git, command] # optional; default all five commands: verified-against: run: ["jq", "-r", ".version", "package.json"] source-of-truth: run: ["node", "scripts/source-for.mjs", "{path}"] # per file timeout: 30 # seconds; default 60| Key | Type | Required | Description |
|---|---|---|---|
run | string[] | yes | The argv, never a shell string. A user who wants a shell writes ["sh", "-c", "…"] and owns the quoting. An element containing {path} makes the command per file. |
timeout | integer | no | Seconds before a running command is killed. The default is 60. |
The rules:
- The value is the trimmed stdout, and only structured JSON is parsed.
1.4.2is the string1.4.2.["a","b"]is a list.{"name":"operator","version":"1.4.2"}is an object, which isverified-against’s checker form. Only{,[and"open a parse, so a bare scalar keeps the characters the command printed: a version1.10stays1.10rather than becoming the number1.1. Empty stdout with exit 0 is null, meaning no fact, which is never stale. - A command that floods or hangs is a failure, not a value. Output past 8 MiB on either stream, or a run past the entry’s
timeout, kills the command and makes the source unavailable. A command reports one field’s value. {path}chooses per file; its absence chooses per run. An argv containing{path}runs once per document, with the document’s run label substituted, so a script can answer for one page. Without it the command runs once, and the one value is copied to every document.- A command may only derive a field no built-in source claims. A
command for
last-updatedis a config error (exit 2):derive.commands.last-updated targets a field git already derives; a command may only derive a field no built-in source claims. A key a URL manifest owns and$schemaare refused the same way.fieldsmay name any key that has a command, and aderive:with onlycommands:is valid. - The source is
command, once.sourceslists it beside the other four, and the default includes it. Excluding it makes every command field derive null. The evidence string is the argv joined with spaces,{path}already substituted, so a reader can run it by hand. - Commands run in the config file’s directory, or the working directory when there is no config. The environment is inherited. Nothing is cached, because only the operator knows what a command reads.
- A command that cannot answer stops the run. A non-zero exit, a timeout,
or a program that is not on
PATHmakes thecommandsource unavailable, exit 2. It is never a null that reads as current. The three messages:
manni: command source unavailable: `jq -r .version package.json` failed (exit 2): jq: error: Could not open package.json; narrow --sources or --fieldsmanni: command source unavailable: `jq` is not on PATH (derive.commands.verified-against); narrow --sources or --fieldsmanni: command source unavailable: `node scripts/source-for.mjs docs/install.md` timed out after 60s; narrow --sources or --fieldsThe rules
Section titled “The rules”- The commit that made the fact is the authority. For a git-derived date, that commit is the one that added the path, or the one that last changed the body. If that commit also set the field, the value it set is the derived value. Otherwise the commit’s author date is. A squash commit carrying both the body edit and the stamp therefore agrees with itself, whatever date GitHub or GitLab gave it.
- The body is everything outside the metadata block. A frontmatter-only
edit is not a body change, so a bulk stamp does not move
last-updated. Formats with no fenced block count any change as a body change. That covers HTML, XML, DITA, and the native reStructuredText and AsciiDoc headers. - Stale is a finding; unknown is not. A managed field whose asserted
value differs from a non-null derived value is a finding under the rule id
derived:stale/derived. A null derived value is no finding. That covers no approval yet, an uncommitted file, and a path outside a repository. Lists compare as sets, so reorderingauthorsis not drift. - A source that cannot answer stops the run. Git missing, not a
repository, or a shallow clone.
ghorglabmissing or not logged in. Acodeownerspath that does not exist. A command that exits non-zero, times out, or is not onPATH. Each is exit 2, naming the source and the fix. A check that silently did not run would be a green gate over nothing.--no-deriveonvalidate,--no-derivedonget, and--sourcesnarrowing are the deliberate opt-outs. The rule holds for reads too.getand aquerynaming thederivedorresolvedtable consult every source a named field needs, among thosederive.sourcesallows, and the default is all five. Aqueryreadingowneralone asks onlycodeowners; one selecting*asks every source. A machine withoutghorglabtherefore setssources: [git, codeowners], or a read that needs GitHub or GitLab is exit 2. One absence is a fact rather than a failure. A repository with no CODEOWNERS file at all has declared no owners, soownerderives null and the run says so once on stderr. Only acodeowners:path that names a missing file is exit 2. - Only
derivewrites a managed field.queryrefuses anUPDATE,INSERTorDELETEthat would touch one, before any file is written.fillnever proposes one, and reports the skip. - The comparison runs on every
validate, scoped or not. Unlike checks, it is a per-file fact, so a pre-commit hook passing staged files judges them too. The baseline records a stale stamp like any other finding. provenanceneeds a fenced metadata block to live on the page. In HTML, XML and DITA the metadata is part of the body a pin hashes, so a stamp would change the lines it pins.deriverefuses that file, exit 1:provenance cannot be stamped into the page: in the "html" format the metadata is part of the body it pins. Keep provenance in an externalMetadata manifest.A manifest holds the record for those pages.- An emptied record is removed. When every range of a page loses its
evidence,
deriveremovesprovenancefrom the page. In a manifest it removes the key from the page’s entry, and an entry left with no keys goes too.provenancehasminItems: 1, so[]would fail its schema.
What each command does
Section titled “What each command does”| Command | With derive: |
|---|---|
validate | Derives once for the run, then compares each file’s managed fields. A stamp that disagrees with a non-null derived value is a derived:stale finding at the field’s line, exit 1. provenance files one finding per changed, stale or unset range, at the range’s first file line. --no-derive skips the comparison for one run. |
derive | Stamps every managed field whose derived value is non-null and differs from the document, through the same writer fill uses. provenance is written range by range, into the manifest when one owns it. --generated-by and a <path>:L1-L2 range attribute lines to a machine. --dry-run reports without writing. --check implies the dry run, and a stale or unset field is a finding, exit 1. A provenance range that only moved is not a finding. See meta derive. |
get | Prints the resolved value of each field, with its origin on the same line. manni meta get owner docs/install.md prints docs/install.md: owner=@platform-docs (derived, codeowners: .github/CODEOWNERS:12). provenance prints its ranges in file lines, as provenance=lines 12-31 claude-fable-5; lines 44 claude-sonnet-5 (derived, git: blame 9b0e2c1). Derivation is on by default, and --no-derived prints only what the document stores. --derived is accepted and inert. |
query | Two read-only tables, derived and resolved, each built when a statement names it. derived is what the evidence says; resolved is the asserted value where there is one and the derived value otherwise, with _origin naming which. A write to a managed key through docs is refused (exit 2) before any file is written. |
fill | A managed field is never a candidate. It is reported with skipReason: "managed", so the omission is visible in the report. provenance and meta-provenance are never candidates either, and are not reported. fill removes both from what it sends a model, and writes meta-provenance for the fields it fills. |
A run over a page whose body moved after its stamp:
$ manni meta derivedocs/install.md last-updated 2026-08-20 → 2026-09-07 (git: body changed in 424f71a) owner (unset) → ["@platform-docs"] (codeowners: .github/CODEOWNERS:12)docs/faq.md current2 files, 1 changed, 2 fields written$ manni meta validatedocs/install.md /last-updated last-updated says 2026-08-20; git says 2026-09-07 (body changed in 424f71a) — run manni meta derive (line 9) [derived:stale]1 file checked, 1 failedThe write refusals:
$ manni meta query "UPDATE docs SET \"last-updated\" = '2026-09-07'"manni: "last-updated" is managed by derive; run manni meta derive instead. exit 2$ manni meta query "UPDATE derived SET owner = 'x'"manni: SQL error: cannot modify derived because it is a view; a collection or the derived table is read-only exit 2$ manni meta query "UPDATE resolved SET owner = 'x'"manni: SQL error: cannot modify resolved because it is a view; the resolved table is read-only — it is `docs` and the evidence joined, so write to docs, or stamp the evidence with manni meta derive. exit 2Finding identity
Section titled “Finding identity”A stale stamp carries the schema ref derived:stale, the keyword derived,
and /<field> as its instance path, at the field’s line in the document:
{ "schema": "derived:stale", "keyword": "derived", "instancePath": "/last-updated", "message": "last-updated says 2026-08-20; git says 2026-09-07 (body changed in 424f71a) — run manni meta derive", "line": 9}A provenance finding differs in two ways. Its line is the first file line
of the range, so the github and sarif formats annotate the prose. Its
subject is provenance <integrity>, carrying the recorded pin, or the
derived one for an unset range. A baseline that forgives one range therefore
does not forgive the others.
{ "schema": "derived:stale", "keyword": "derived", "subject": "provenance sha256-537bc424c34e7cbbd04cc73c49df009628e35a3ab292f6da30ad1f071bfe8b62", "instancePath": "/provenance", "message": "provenance lines 16-18 changed since claude-fable-5 wrote them — run manni meta derive", "line": 16}The ref is shaped like check:<name>, so the rule id everywhere, in SARIF,
JUnit and the baseline, is the ordinary join derived:stale/derived. The
derived segment is reserved as a first segment beside check and
external, and is listed with the other reserved rule
ids. The
finding’s file names the manifest whenever one supplied the value, and line
then refers to that file. A value the document holds sets neither.
Exit codes
Section titled “Exit codes”| Exit | When |
|---|---|
2 | The config’s derive: is malformed. fields is empty, repeats a name, names a field that is not derivable, or names a key a collection’s URL manifest owns. sources is empty or repeats a source. codeowners names a file that is not there. A commands entry targets a built-in field, a key a URL manifest owns or $schema, or has no run. machines is empty, not a list of strings, repeats a glob or holds a blank one. A requested source cannot answer: no git, no repository, a shallow clone, gh or glab missing or not logged in. A command failed, timed out or is not on PATH. derive was given -, a non-derivable --fields name or one a URL manifest owns, no fields at all, or a findings format without --check. --generated-by or a range came without provenance in the fields, or a range came without --generated-by. A range is reversed, past the end, inside the frontmatter, or contradicted by evidence, or does not name one file the run reads. A manifest that owns provenance cannot hold the record. A query write would touch a managed key. |
1 | validate found a managed field whose stamp disagrees with the evidence. derive --check found a stale or unset managed field. derive could not stamp provenance on a page whose metadata is not fenced. |
The split is the one every command already follows. Exit 2 means the run could not establish the facts and produced no verdict. Exit 1 means it did, and the documents still need work.
Example
Section titled “Example”collections: - name: pages paths: - "docs/**/*.md" - "docs/**/*.mdx" exclude: - "docs/drafts/**" url: https://docs.example.com/ - name: api paths: - "docs/api/**/*.{md,mdx}" - name: guides paths: - "docs/guides/**/*.{md,mdx}" externalMetadata: - file: ./guides-meta.yaml keys: [source, jira]
meta: register: - ./schemas/house/ # house:page:1.0.0 and house:page-strict:1.0.0 strict: true schemas: - house:page:1.0.0 # after the eleven defaults overrides: - collection: api schemas: - ./schemas/api.schema.json # replaces the set for API pages - collection: guides defaults: true schemas: - ./schemas/guide.schema.json # the defaults, then the guide schema checks: - name: unique-slugs query: >- SELECT _path AS path, 'slug' AS key, slug AS message FROM docs WHERE slug IN (SELECT slug FROM docs GROUP BY slug HAVING count(*) > 1)api and guides overlap pages deliberately. pages is the corpus a bare
manni meta validate reads; the other two are what --collection guides,
overrides[].collection and FROM guides name. A file under docs/api/ is a
member of both pages and api, which is fine. It resolves one schema set by
first-match-wins, and appears in both views.
Discovery
Section titled “Discovery”manni.config.yaml is shared by every tool under manni: one file per
repository, one top-level key per tool, plus the one key that is nobody’s. The
metadata tool reads collections: and meta: and ignores their
siblings, so a docevals: or a11y: section beside them never changes what
meta does. The document set, being declared once, is the same set for all of
them.
When you do not pass -c/--config, manni meta searches the current working
directory and then each parent directory in turn. In every directory it looks
for these files, in order:
manni.config.yaml, thenmanni.config.yml, read at theirmeta:key. A family file counts as the metadata tool’s config when it carriesmeta:,collections:,tools:,encryptionKey:orproviders:. One with a family key and nometa:still describes this repository. It stops the search and hands the tool an empty section plus the family keys. A family file with none of the five belongs to a sibling tool and is skipped, so the search continues.docmeta.config.yaml, thendocmeta.config.yml: the metadata tool’s own file from before the family file. Its whole document is themeta:section, with no wrapper key, so it can carry nocollections:at all. A legacy file that still sayspaths:is refused with the same messagemeta.pathsgets, which is how its owner learns to migrate. Read with a warning that the name is deprecated.
The first file found wins and the search stops there, and that now includes
a file whose only relevant key is collections:. A repository mid-migration,
with a collections:-only file above a per-tool file below it, sees the outer
one win. The walk used to continue upward. Configs are never merged. schemas is a set every file must satisfy in full, and overrides is
first-match-wins ordered. A partial merge would quietly redefine what the
contract means. A manni.config.yaml inside docs/ therefore shadows the
one at the repo root for runs started inside docs/. That is the escape hatch
for a subtree with genuinely different rules. For per-directory rules within one
contract, prefer overrides instead.
Each warning is said once per run, on stderr, so json and the other machine
formats stay parseable. There is no flag to silence it; renaming the file is
the fix, and takes one command:
mv docmeta.config.yaml manni.config.yamlthen indent its contents under a meta: line.
The search stops at the project boundary, which is the first directory
containing a .git entry, and that directory is itself searched. A git file,
what a worktree or a submodule carries, counts as a boundary just as a directory
does. If no ancestor is a repository, only the current working directory is
searched; manni meta never reads a config from your home directory or from /.
If no config is found, manni meta runs on the built-in default schema
set. Pass --no-config to
skip discovery deliberately, which is useful for a one-off run against the
defaults.
When you pass -c/--config <path> explicitly, no search happens at all and that
file must exist; a missing config file is an error (exit 2). The file is read by
the same rule whatever it is named. If it has a meta:, collections:,
tools:, encryptionKey: or providers: key it is read as a family file. If it has none of them, the
whole document is the meta: section. So -c ./anything.yaml works for
both shapes without a flag, and an explicit path is never warned about.
When a run is governed by a config, manni meta says so before the report:
Using manni.config.yaml (../..)The path in parentheses is the config’s directory relative to where you ran the
command. The line goes to stdout in pretty output, and to stderr for every
machine format. json, github, sarif, and junit all keep it on stderr, so
machine-readable output stays parseable.
What relative paths are relative to
Section titled “What relative paths are relative to”A config’s relative paths mean what they look like they mean to whoever is editing the config. They resolve against the config file’s own directory, not the directory you happened to run from. That applies to:
collections[].pathsandcollections[].exclude, and so to every membership decision, which compares a file’s label relative to that same directory;collections[].externalMetadata[].file, and every document path inside the manifest it names;tools.vale.config,tools.dita-ot.home,tools.doc-detective.configandtools.claude-cli.mcpConfig;overrides[].files;baseline, and any local.jsonschema file named inschemasoroverrides.
So a config means the same thing whether you run from the repo root, from
docs/, or from a monorepo package script.
This matters most for baseline. Resolving it against the working directory
instead would mean a run from a subdirectory quietly found no baseline. It would
then report the entire recorded backlog as new. For the same reason, the schema
reference inside a violation fingerprint is
measured against the config’s directory too. A baseline recorded from the repo
root then matches one recorded from docs/, byte for byte.
Two things stay relative to the current working directory instead, because a person standing in a shell typed them there:
- positional
[paths...]on the command line, and-s/--schema; - a document’s own
$schemavalue.
A single run uses either positional paths or the selected collections’
paths, never both, so there is exactly one base per run. When the run takes its
targets from collections:, reported file paths are relative to the config’s
directory.
How config merges with CLI flags
Section titled “How config merges with CLI flags”CLI flags and config values combine per setting. Some flags override the corresponding config value; others merge with it.
| Setting | CLI flag | Config key | How they combine |
|---|---|---|---|
| Inputs | [paths...] | collections[].paths | CLI positional paths override the collections entirely. The collections supply the inputs only when no positional inputs are given, - included. --collection is a request rather than a fallback, so it is honoured even with - beside it. |
| Collections | --collection | collections[].name | The flag narrows which collections supply the inputs, in declaration order. Omitted, every declared collection does. It needs a config to select from, and refuses beside positional paths. |
| Excludes | --exclude | collections[].exclude | Neither overrides nor merges, because they do different jobs. --exclude removes files from the run. A collection’s exclude removes files from that collection. The effective run-level exclude set is every --exclude glob plus the default ignores. |
| Schemas | -s/--schema | schemas / defaults / strict / overrides | CLI --schema overrides all four for every file, and the default set with them. A registered id still resolves on it. See precedence. |
| Config discovery | --no-config | n/a | --no-config skips discovery entirely, so no config value applies. It sets the same option as -c/--config, so if you pass both, whichever comes later on the command line wins. |
| Empty input sets | --allow-empty | allowEmpty | The flag overrides the config key, and only in the permissive direction: passing it turns the check off. Omitting it falls back to config, then to off. |
| Fill settings | --confidence, --provider, --model, --max-turns, --chunk-chars, --concurrency | fill.* | Each flag overrides its matching fill key. An unset flag falls back to config, then to the built-in default. An unset --provider or --model falls back to fill.provider or fill.model, then to providers:, then to auto. |
| Offline | --offline | offline | The flag overrides the config key, and only in the restrictive direction: passing it turns egress off. Omitting it falls back to config, then to off. |