Keep maintainer metadata out of delivered pages
A page’s metadata serves two readers. Some fields are for whoever fetches the
page: title, description, lifecycle, the questions it answers. Others are
for the people who maintain it: owner, authors, review-interval, the evals
CI runs. Some platforms serve frontmatter unchanged in the markdown variant
coding agents and retrieval pipelines fetch. On that surface every maintainer
field is bytes an agent reads and cannot use.
x-manni-location is how your standard says which is which. page keeps a
field in the document. external keeps it in the collection’s
external-metadata manifest, a YAML
file beside the pages. This page follows one standard from the mark, through
the report, to the move, and then through every command that writes a value.
Every transcript below is real output from the built tool.
Mark the property
Section titled “Mark the property”The mark sits on the property, beside the rules it keeps:
{ "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", "properties": { "title": { "type": "string", "x-manni-location": "page" }, "authors": { "type": "array", "items": { "type": "string" }, "x-manni-location": "external" }, "owner": { "type": "string", "x-manni-location": "external" } }}No mark means no preference, and every command behaves as it did before the
keyword existed. The mark changes where a value belongs, never whether it is
valid. owner is still a string wherever it lives.
The mark counts wherever the validator evaluates it: a $ref, an allOf, a
referenced built-in, and the if/then branches the validator takes. Only a
top-level property’s mark has an effect, because a manifest owns top-level
keys. When two schemas in a file’s set disagree, the later one wins, so a house
schema listed after a vocabulary refines it. The schema resolution
reference has the
rules and the three compile errors.
What validate reports
Section titled “What validate reports”Two pages carry values the schema prefers elsewhere:
---title: Installauthors: [ada]owner: platform---The config declares one collection, with no manifest yet:
meta: schemas: [./steward.schema.json]collections: - name: site paths: ["docs/**/*.md"]$ manni meta validateUsing manni.config.yaml (.)⚠ docs/faq.md /owner warning "owner" is stored in the page; steward.schema.json prefers external metadata. Run manni meta relocate. (line 3) [location:external]⚠ docs/install.md /authors warning "authors" is stored in the page; steward.schema.json prefers external metadata. Run manni meta relocate. (line 3) [location:external] /owner warning "owner" is stored in the page; steward.schema.json prefers external metadata. Run manni meta relocate. (line 4) [location:external]
2 files checked, 2 passed, 0 failed, 0 errors, 3 warnings# exit 0Both pages pass. A misplaced value is a warning, so marking a vocabulary on a large docset never turns CI red overnight. The finding names the schema whose mark decided, at the line the value sits on.
The other direction has its own finding. Here a manifest supplies title,
which the schema prefers in the page:
$ manni meta validateUsing manni.config.yaml (.)⚠ docs/faq.md /title warning "title" is stored in manifest docs-meta.yaml; steward.schema.json prefers the page. Run manni meta relocate. (docs-meta.yaml:3) [location:page]⚠ docs/install.md /owner warning "owner" is stored in the page; steward.schema.json prefers external metadata. Run manni meta relocate. (line 2) [location:external] /title warning "title" is stored in manifest docs-meta.yaml; steward.schema.json prefers the page. Run manni meta relocate. (docs-meta.yaml:5) [location:page]
2 files checked, 2 passed, 0 failed, 0 errors, 3 warnings# exit 0| Finding | Where it points | What it means |
|---|---|---|
location:external |
The page line | A value in the page whose schema prefers external metadata |
location:page |
The manifest line | A value a manifest supplies whose schema prefers the page |
Two cases file no location finding. A key a manifest owns and a page also
carries is already an external:owned error, so it is not reported twice. And
under a field join,
the join field stays in the page whatever its mark, because it is how the page
finds its entry.
A page in none of several collections has nowhere to go, and the finding says so instead of suggesting a move:
/owner warning "owner" is stored in the page; steward.schema.json prefers external metadata, and this document is in none of the 2 collections. (line 2) [location:external]In every reporter
Section titled “In every reporter”$ manni meta validate -f githubUsing manni.config.yaml (.)::warning file=docs/faq.md,line=3::[location:external] /owner "owner" is stored in the page; steward.schema.json prefers external metadata. Run manni meta relocate.::warning file=docs/install.md,line=3::[location:external] /authors "authors" is stored in the page; steward.schema.json prefers external metadata. Run manni meta relocate.::warning file=docs/install.md,line=4::[location:external] /owner "owner" is stored in the page; steward.schema.json prefers external metadata. Run manni meta relocate.| Format | What a location finding looks like |
|---|---|
pretty |
The word warning before the message, ⚠ on a file with no errors, and location:external or location:page in the bracket |
json |
An entry in errors[] with "keyword": "location", "schema": "location:external", "subject": "owner" and "severity": "warning". A location:page entry adds "file": "docs-meta.yaml" |
github |
::warning file=…,line=3::[location:external] /owner …, as above. A location:page annotation sits on the manifest file |
sarif |
"ruleId": "location:external/location" at "level": "warning" |
junit |
Not a <failure>. The testcase passes, as it does for every warning |
A baseline records warnings like any other finding.
Move the values with relocate
Section titled “Move the values with relocate”manni meta relocate puts every
value where its mark says. Preview first:
$ manni meta relocate --dry-runUsing manni.config.yaml (.)Would create site.metadata.yaml; collections[site].externalMetadata[0] would own owner, authors.docs/faq.md owner → site.metadata.yamldocs/install.md authors → site.metadata.yaml owner → site.metadata.yaml2 files, 3 values would move to 1 manifest# exit 0Then run it:
$ manni meta relocateUsing manni.config.yaml (.)Created site.metadata.yaml; collections[site].externalMetadata[0] owns owner, authors.docs/faq.md owner → site.metadata.yaml:2docs/install.md authors → site.metadata.yaml:4 owner → site.metadata.yaml:62 files, 3 values moved to 1 manifest# exit 0Three files changed. The page keeps only what an agent uses:
---title: Install---The manifest holds the rest, keyed by page:
docs/faq.md: owner: platformdocs/install.md: authors: - ada owner: platformAnd the config declares the manifest. The edit keeps comments and key order:
meta: schemas: [./steward.schema.json]collections: - name: site paths: ["docs/**/*.md"] externalMetadata: - file: ./site.metadata.yaml keys: [owner, authors]validate now reads owner and authors from the manifest, checks them
against the same schema, and reports nothing:
$ manni meta validateUsing manni.config.yaml (.)✓ docs/faq.md✓ docs/install.md
2 files checked, 2 passed, 0 failed, 0 errors# exit 0Both directions in one run
Section titled “Both directions in one run”relocate moves a value back into the page just as readily. Over the
two-finding example above, title comes out of the manifest and owner goes
in:
$ manni meta relocateUsing manni.config.yaml (.)Removing title from docs-meta.yaml's keys moves it into every page in collection site.Adding owner to docs-meta.yaml's keys moves it out of every page in collection site.docs/faq.md title ← docs-meta.yamldocs/install.md title ← docs-meta.yaml owner → docs-meta.yaml:32 files, 3 values moved: 2 into pages, 1 into 1 manifest# exit 0When a move back empties a manifest’s keys:, the entry leaves the config and
the file stays on disk, with one more line:
docs-meta.yaml no longer owns any keys and is no longer declared; delete it when you are ready.
A key covers the whole collection
Section titled “A key covers the whole collection”keys: is per collection, not per page. Adding a key moves it out of every
member page, or each page still carrying it would fail external:owned. So a
narrowed run widens, and says by how much:
$ manni meta relocate docs/install.md --fields ownerUsing manni.config.yaml (.)Adding owner to docs-meta.yaml's keys moves it out of every page in collection site, 2 beyond the paths you named.docs/install.md owner → docs-meta.yaml:3… (2 more files)3 files, 3 values moved to 1 manifest# exit 0Removing a key moves it back into every member page for the same reason.
Where a missing manifest comes from
Section titled “Where a missing manifest comes from”relocate creates what a move needs:
| Your config | What relocate writes |
|---|---|
| The page’s collection has a local manifest | The keys are appended to its keys: |
| The collection has no manifest | <collection>.metadata.yaml beside the config file, declared under externalMetadata: |
No collections: at all |
A collection named default whose paths: are the targets you typed, and default.metadata.yaml |
| One collection, and the target is outside it | The target, appended to that collection’s paths: |
| Several collections, and the page is in none | Nothing. The value stays |
Each change to collections: gets a line of its own:
- With no collections,
manni meta relocate docs/ guides/intro.mdprintsCreated collection default (paths: docs/**, guides/intro.md) and default.metadata.yaml; it owns authors, owner. - With one collection and a target outside it,
manni meta relocate notes/printsAdded notes/** to collection site's paths; docs-meta.yaml now owns owner, authors.
Growing a collection’s paths: also grows what manni cite and manni a11y
see for it, which is why the line always names the path. The configuration
reference shows each
config before and after.
A value that stays
Section titled “A value that stays”relocate never guesses. A value it cannot move stays where it is, is named
with its reason, and the run exits 1:
$ manni meta relocate --dry-runUsing manni.config.yaml (.)api/joined.md owner → api-meta.yamlapi/nojoin.md owner stays: this document has no id, which api-meta.yaml joins ondocs/differ.md owner stays: the page and docs-meta.yaml hold different valuesdocs/native.rst owner stays: the rst format cannot write this document's metadatadocs/same.md owner → docs-meta.yaml team → docs-meta.yaml5 files, 3 values would move to 2 manifests, 3 stayed# exit 1team has no mark. It moves because docs-meta.yaml already owns it, and a
page carrying an owned key fails external:owned whatever the schema says.
docs/differ.md holds one owner and the manifest another. Moving either copy
would silently change what validation sees, so the value stays until someone
decides. The other two reasons are a page in none of several collections, and
a manifest that is a URL. A run fetches a URL manifest and cannot write it.
What the writers do
Section titled “What the writers do”derive, fill and query follow the manifest, not the mark. A key a local
manifest owns is written into the page’s entry there, and the page is not
touched.
derive stamps a managed field into the manifest and names where it went:
$ manni meta deriveUsing manni.config.yaml (.)docs/faq.md created (unset) → 2026-09-13 (git: added in a32e411 (2026-09-13)) owner (unset) → ["@platform-docs"] (codeowners: .github/CODEOWNERS:1) → site-meta.yaml:5docs/install.md created (unset) → 2026-09-13 (git: added in a32e411 (2026-09-13)) owner ["@old-team"] → ["@platform-docs"] (codeowners: .github/CODEOWNERS:1) → site-meta.yaml:2
2 files, 2 changed, 4 fields written# exit 0created has no owner, so it went into the page. query writes an owned key
the same way, and the bracket names the manifest:
$ manni meta query "UPDATE docs SET owner = 'identity' WHERE _path = 'docs/auth.md'" --dry-runUsing manni.config.yaml (.)docs/auth.md: owner: platform -> identity [docs-meta.yaml]1 change across 1 file — dry run; run again without --dry-run to apply# exit 0fill splices an accepted proposal into the manifest, and its report line ends
with the destination, as in /owner platform 0.92 → site-meta.yaml.
| The key | derive, fill, query |
|---|---|
| Owned by a local manifest | Written into the manifest. This holds even when its schema prefers the page, and validate then reports location:page. |
| Owned by a URL manifest | Refused: "owner" is owned by manifest https://example.com/owners.yaml, which is fetched and cannot be written; set it in that repository. |
Owned by a local manifest of a collection --collection leaves out |
derive and fill read that manifest too, so a value it holds is never taken for missing, and write there. query refuses: "docs/auth.md": "owner" is owned by manifest docs-meta.yaml of collection b, which --collection leaves out; include it or edit the manifest. |
Prefers external, and no manifest owns it |
Offered a manifest on a terminal. Otherwise written to the page, with one warning. |
Prefers page, or carries no mark, and is not owned |
Written to the page, as always. |
The warning names the fields and the command that fixes them:
$ manni meta deriveUsing manni.config.yaml (.)manni: wrote owner to 2 pages in collection site; the schema prefers external metadata, and no manifest owns it. Run manni meta relocate to move it.docs/faq.md created (unset) → 2026-09-13 (git: added in 27b3d7c (2026-09-13)) owner (unset) → ["@platform-docs"] (codeowners: .github/CODEOWNERS:1)docs/install.md created (unset) → 2026-09-13 (git: added in 27b3d7c (2026-09-13)) owner (unset) → ["@platform-docs"] (codeowners: .github/CODEOWNERS:1)
2 files, 2 changed, 4 fields written# exit 0For a page in none of several collections it reads
manni: wrote owner to 1 page that is in none of the 2 collections; the schema prefers external metadata, and only a collection has a manifest.
Under --dry-run both read would write.
The question on a terminal
Section titled “The question on a terminal”On a terminal, a run offers the move instead of only warning about it. The
question goes to stderr, and the answer is y or anything else.
validate asks after its report, once per collection. Over the first example:
manni: in collection site, 3 values in 2 pages prefer external metadata.Move them (create site.metadata.yaml)? [y/N]A yes runs relocate for those pages and keys, and prints its report on
stderr, so a -f json report on stdout stays machine-readable. The exit code
is still the report’s.
derive, fill and query ask before a value that prefers external metadata
is written. fill asks before its first model request:
manni: collection site has no manifest for owner, which the schema prefers in external metadata.Create site.metadata.yaml and add it to manni.config.yaml? [y/N]When pages already carry the value, the question ends
, and move them out of 2 pages?. On yes, relocate runs and the write lands
in the new manifest. On no, the run writes to the page and prints the warning.
A page an INSERT creates is written with the warning, never offered.
Nothing is asked when any of these hold, and the warning or finding stands on its own:
- stdin or stderr is not a terminal, which covers CI, the pre-commit hook and the Action;
- the run reads
-; --dry-run,derive --check, or--no-config;- the page has no possible home;
- a baseline suppresses the finding.
That last one is how someone who has decided to keep a value in the page stops
being asked. There is no --yes flag. relocate is the non-interactive way to
accept.
Choose which fields to mark
Section titled “Choose which fields to mark”One question decides a mark. Would a third-party or user-owned agent fetching
the page act on this field? If yes, page. If the field serves authoring,
review or CI, external.
last-updatedispage. An agent uses it to judge whether a page is fresh enough to trust.ownerandreview-intervalname people and a cadence, and areexternal.audiencesandintentarepage, because an ingester filters and routes on them.personasandjourneysare internal names that mean nothing outside.lifecycleandreplaced-byarepage. An agent must seedeprecatedand follow the replacement.- Evals and citations are CI’s, and are
external.
The mark is a statement a delivery-side tool can read too. A served markdown
variant can keep the page fields and drop the rest, from your schema, rather
than from an allowlist of its own.
The proposed vocabularies mark every field this way, in new draft revisions:
| Draft | page |
external |
|---|---|---|
manni:core:1.0.0-proposal.4 |
title, description, id, type, keywords, language, locale |
|
manni:stewardship:1.0.0-proposal.3 |
last-updated |
authors, owner, stakeholders, reviewed-by, created, last-reviewed, review-interval, verified-against, source-of-truth |
manni:audience:1.0.0-proposal.2 |
audiences, intent |
personas, journeys, visibility |
manni:lifecycle:1.0.0-proposal.2 |
lifecycle, replaced-by |
supersedes, remove-by |
manni:structure:1.0.0-proposal.2 |
applies-to, not-applicable-to, concepts, prerequisites, next-steps, related-pages |
|
manni:ai-context:1.0.0-proposal.3 |
risks, sample-questions |
provenance, meta-provenance |
manni:evals:1.0.0-proposal.4 |
evals, eval-suite, eval-skip |
|
manni:kg:1.0.0-proposal.3 |
kg |
|
manni:artifact-evals:1.0.0-proposal.4 |
metadata |
|
manni:citations:1.0.0-proposal.4 |
citations |
A mark covers a whole top-level key. metadata is external, so an Agent
Skills metadata map moves entire, not only its evals.
To keep one of these fields in the page anyway, list your own schema after the vocabulary and mark the property there. The later schema wins:
{ "properties": { "owner": { "x-manni-location": "page" } }}