Skip to content

Stamp stewardship fields from evidence

A managed field is a stewardship field whose value comes from evidence rather than from a person’s memory. 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 you name. manni meta derive stamps the value into the page. manni meta validate compares what is stamped against what the evidence says, and a stamp that disagrees fails the build.

The page stays the truth for validation. Nothing is merged in from outside, and nothing about the schema changes. What changes is that a stamped date can no longer be quietly wrong.

The stewardship vocabulary asks three questions of every page. Who wrote it, and who answers for it now? When did it change, and when was it last reviewed? What was it verified against? Each answer is a field, and a schema can require every one of them.

A schema cannot make the answers true. Someone fixes a paragraph at the end of a long day, and last-updated keeps last month’s date. A colleague rewrites a section, and authors keeps the original name. The page moves to another team in CODEOWNERS, and owner never hears about it. A reviewer approves the pull request, and nobody transcribes the approval. Every field is present and well-formed, so the run is green.

Batami Gold’s comment on the vocabulary named the gap. She asked for a built-in system that forces whoever updates a page, human or agent, to answer these questions at check-in. This page is that system, and it is honest about its reach. It can answer the first two questions from evidence. Git knows when the body changed and who changed it. CODEOWNERS knows who answers for the path. GitHub or GitLab knows who approved the change and when. Six fields follow: created, last-updated, authors, owner, reviewed-by, and last-reviewed. The third question is split. What a page was verified against is a judgment. The version it should have been verified against is a fact a program can read, and a command in the config reads it. Who has a stake in a page is a judgment no history holds. For that, required in the schema is the whole enforcement, and the finding it produces is the reminder.

A seventh managed field answers a question the vocabulary does not ask. Which lines of the page did a machine write? provenance records them as pinned ranges, from the same git history. See which lines a machine wrote is that journey.

Name the fields you want managed. Everything else is optional.

  1. List the managed fields. Only these are stamped, and only these are compared. A field you leave out stays a plain asserted field, exactly as before.

    manni.config.yaml
    collections:
    - name: pages
    paths: ["docs/**/*.md"]
    meta:
    derive:
    fields: [created, last-updated, authors, owner]
  2. Narrow the sources, if you must. The default names all five, command included, and the origin remote picks which of github and gitlab is asked. A machine with no gh can leave both out, and a field whose only source is excluded is never stamped and never stale.

    manni.config.yaml
    meta:
    derive:
    fields: [created, last-updated, authors, owner]
    sources: [git, codeowners]
  3. Point at CODEOWNERS, if it is somewhere unusual. By default the file is found where GitHub or GitLab looks for it.

    manni.config.yaml
    meta:
    derive:
    fields: [owner]
    codeowners: .github/CODEOWNERS

A managed field cannot also be a key an external-metadata manifest owns. The two would disagree about where the value lives, so the config is refused.

From the repository, over every configured collection:

Terminal window
npx -y @hawkeyexl/manni meta derive
docs/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 current
2 files, 1 changed, 2 fields written

Each line says what changed, and the parenthesis says why. The body of install.md last changed in commit 424f71a, so that commit’s date is what last-updated should say. Line 12 of CODEOWNERS names the owner. faq.md already agreed with the evidence, so nothing was written.

derive writes by default, like fill and query. Pass --dry-run to see the same report with nothing touched. Pass --check to make a stale field a finding instead, which is what CI runs.

To see the evidence for one field without writing anything:

Terminal window
npx -y @hawkeyexl/manni meta get last-updated docs/install.md
docs/install.md: last-updated=2026-08-20 (asserted; git says 2026-09-07, body changed in 424f71a)

The page asserts one date and git says another, so the line says both. No flag is needed for that, and See what a page resolves to has the rest of the read surface.

Once a field is managed, validate holds every page to it:

$ manni meta validate
docs/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 failed

The message names the fix. Run manni meta derive, commit, and the page is current.

A page with no owner in its frontmatter can still have an owner. CODEOWNERS may cover the path, and nobody wrote it down. get answers that without a flag, because a read gives back the resolved value. That is the asserted value where the page has the key, and the derived one where it does not.

Terminal window
npx -y @hawkeyexl/manni meta get owner docs/install.md docs/faq.md
docs/install.md: owner=@platform-docs (derived, codeowners: .github/CODEOWNERS:12)
docs/faq.md: owner=@docs-team (asserted)

install.md says nothing about ownership, and CODEOWNERS line 12 answers for it. faq.md names its own owner, which is what the page publishes, so that is what comes back. To see only what the pages store, and consult nothing:

Terminal window
npx -y @hawkeyexl/manni meta get owner docs/install.md --no-derived
docs/install.md: owner=(unset)

Across a corpus, the same question is SQL. query builds a read-only resolved table whose _origin column says, per field, which side won:

Terminal window
npx -y @hawkeyexl/manni meta query "SELECT _path, owner, _origin ->> '\$.owner' AS origin FROM resolved"

So “the pages CODEOWNERS covers that nobody wrote an owner into” is one WHERE clause:

Terminal window
npx -y @hawkeyexl/manni meta query "SELECT _path FROM resolved WHERE _origin ->> '\$.owner' = 'derived'"

Batami’s first question was “what version did you check this against?” No history answers it, but the repository usually knows the version. It is in package.json, a VERSION file, or one git describe away. A command in the config reads it, and verified-against becomes a managed field like the six above.

manni.config.yaml
meta:
paths: ["docs/**/*.md"]
derive:
fields: [last-updated, verified-against]
commands:
verified-against:
run: ["jq", "-r", ".version", "package.json"]

run is an argv list, never a shell string, so it means the same thing on every platform. The command runs once, in the config file’s directory, and its trimmed stdout is the value for every page. Structured JSON is parsed, so a script that prints {"name":"operator","version":"1.4.2"} derives the object form the vocabulary recommends for a checker. Anything else stays the text the command printed, so a version such as 1.10 is stamped as you wrote it.

Terminal window
npx -y @hawkeyexl/manni meta derive
docs/install.md
verified-against 1.4.1 → 1.4.2 (command: jq -r .version package.json)
1 file, 1 changed, 1 field written

The evidence is the command itself, so anyone reading the report can run it by hand. From then on, a release that moves package.json makes every page still stamped with the old version a finding:

$ manni meta validate
docs/install.md
/verified-against verified-against says 1.4.1; command says 1.4.2 (jq -r .version package.json) — run manni meta derive (line 7) [derived:stale]
1 file checked, 1 failed

To see the two side by side without writing anything:

Terminal window
npx -y @hawkeyexl/manni meta get verified-against docs/install.md
docs/install.md: verified-against=1.4.1 (asserted; command says 1.4.2, jq -r .version package.json)

A field that differs per page takes {path} in the argv. The command then runs once per document, with the document’s path in place of the placeholder, so a script can answer for one page:

manni.config.yaml
meta:
derive:
fields: [source-of-truth]
commands:
source-of-truth:
run: ["node", "scripts/source-for.mjs", "{path}"]
timeout: 30

A command that prints nothing and exits 0 has no answer, and the page is left alone. A command that fails, hangs past its timeout, or is not installed stops the run with exit 2. A shallow clone does the same, for the same reason. The argv is in the message. A command may only derive a field no built-in source claims, so a commands.last-updated entry is refused by name.

Two things have to be true on the runner. The checkout needs history, and the gh or glab CLI needs a login. actions/checkout gives one commit by default, and one commit cannot say when a body last changed. derive refuses a shallow clone rather than guessing, and names the fix, so the step to add is fetch-depth: 0. gh is already on every GitHub-hosted runner, and the workflow’s own token logs it in.

.github/workflows/manni.yml
name: Validate metadata
on: [push, pull_request]
jobs:
manni:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: hawkeyexl/manni@v2
env:
GH_TOKEN: ${{ github.token }}

The Action runs validate, and validate compares every managed field. A stale stamp is an inline annotation on the diff, at the field’s line, with the command to run in the message. The Action has no input for this. Anything you need to pass reaches it through args, and the one flag worth knowing is the opt-out:

- uses: hawkeyexl/manni@v2
with:
args: --no-derive

To annotate what derive would write, rather than only what is stale, add a second step. --check implies a dry run, so it never touches the checkout:

- run: npx -y @hawkeyexl/manni meta derive --check -f github
env:
GH_TOKEN: ${{ github.token }}

On GitLab, the same recipe uses glab, which is one package install on the runner image, logged in with the job token:

.gitlab-ci.yml
validate-metadata:
stage: test
image: node:24
variables:
GIT_DEPTH: 0
GITLAB_TOKEN: $CI_JOB_TOKEN
before_script:
- apt-get update && apt-get install -y glab
script:
- npx -y @hawkeyexl/manni meta validate

reviewed-by and last-reviewed come from the approval itself. A reviewer clicks approve, and GitHub or GitLab now knows who reviewed the change and when. Nothing has to transcribe it. A workflow that runs on the approval event stamps both fields and pushes the stamp to the pull request branch:

.github/workflows/stamp-review.yml
name: Stamp review metadata
on:
pull_request_review:
types: [submitted]
jobs:
stamp:
if: github.event.review.state == 'approved'
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: read
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: 24
- run: npx -y @hawkeyexl/manni meta derive --fields reviewed-by,last-reviewed
env:
GH_TOKEN: ${{ github.token }}
- run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add -A
git diff --cached --quiet || git commit -m "chore: stamp review metadata"
git push

The pull request is still open, so its answer is never cached, and every run reads the approvals as they stand. Before the first approval the derived value is null, which is no finding. So validate does not fail a pull request for a review that has not happened yet. After it, the stamp says who and when, and the next body change starts the cycle again.

The first validate after adding derive.fields reports every page whose body moved since its stamp. On a corpus that stamped dates by hand for a year, that is most of it. Two ways through.

The direct way is to stamp everything once and commit it. That is one manni meta derive, one review of the diff, and one commit. The diff is readable because each line changes one field on one page, and the report says why.

The gradual way is the baseline. Record today’s stale stamps, and from then on fail only on new ones:

Terminal window
npx -y @hawkeyexl/manni meta validate --write-baseline

A stale stamp is a finding like any other, so it rides the same ratchet. The debt stays visible in the N baselined findings line on every run. It shrinks as pages are touched, because a page that goes through derive has nothing left to forgive.

Only derive writes a managed field. That is what makes the stamp trustworthy, so the other writers refuse it by name:

$ manni meta query "UPDATE docs SET \"last-updated\" = '2026-09-07'"
manni: "last-updated" is managed by derive; run manni meta derive instead.

fill never proposes a managed field, and reports the skip so the omission is visible. It never proposes provenance or meta-provenance either, and leaves both out of what it sends a model. It writes meta-provenance itself, listing the fields it filled, so a reviewer knows which values to check. A managed field can still be read everywhere. get prints it, and a corpus check can phrase a rule over it. query gains two read-only tables. derived holds what the evidence says, one row per page, and resolved holds what to believe. So “every page whose stamp disagrees with git” is a SQL question, and so is “every page whose owner nobody wrote down”.

To stop managing a field, remove it from derive.fields. The stamp stays in the page as an ordinary asserted value, and nothing compares it any more.

An agent that edits a page and opens a pull request hits the same gate a person does, and the gate is built for it. The finding’s message is a complete instruction: last-updated says 2026-08-20; git says 2026-09-07 (body changed in 424f71a) — run manni meta derive. An agent reading its own CI output has the command to run, and running it produces a diff that explains itself. There is no prompt to write and no rule to teach. The message is the prompt.

The same holds in the other direction. An agent cannot make a stamp true by typing it, because query and fill refuse a managed field and validate holds the page to the evidence. What it can do is change the body and run derive, which is the honest sequence for a person too.