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 tired updater
Section titled “The tired updater”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.
Configure it
Section titled “Configure it”Name the fields you want managed. Everything else is optional.
-
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: pagespaths: ["docs/**/*.md"]meta:derive:fields: [created, last-updated, authors, owner] -
Narrow the sources, if you must. The default names all five,
commandincluded, and the origin remote picks which ofgithubandgitlabis asked. A machine with noghcan 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] -
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.
Run it locally
Section titled “Run it locally”From the repository, over every configured collection:
npx -y @hawkeyexl/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 writtenEach 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:
npx -y @hawkeyexl/manni meta get last-updated docs/install.mddocs/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 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 message names the fix. Run manni meta derive, commit, and the page is
current.
See what a page resolves to
Section titled “See what a page resolves to”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.
npx -y @hawkeyexl/manni meta get owner docs/install.md docs/faq.mddocs/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:
npx -y @hawkeyexl/manni meta get owner docs/install.md --no-deriveddocs/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:
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:
npx -y @hawkeyexl/manni meta query "SELECT _path FROM resolved WHERE _origin ->> '\$.owner' = 'derived'"Derive a field with a command
Section titled “Derive a field with a command”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.
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.
npx -y @hawkeyexl/manni meta derivedocs/install.md verified-against 1.4.1 → 1.4.2 (command: jq -r .version package.json)1 file, 1 changed, 1 field writtenThe 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 validatedocs/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 failedTo see the two side by side without writing anything:
npx -y @hawkeyexl/manni meta get verified-against docs/install.mddocs/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:
meta: derive: fields: [source-of-truth] commands: source-of-truth: run: ["node", "scripts/source-for.mjs", "{path}"] timeout: 30A 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.
In a pull request
Section titled “In a pull request”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.
name: Validate metadataon: [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-deriveTo 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:
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 validateAfter approval
Section titled “After approval”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:
name: Stamp review metadataon: 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 pushThe 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.
Rolling out on an existing corpus
Section titled “Rolling out on an existing corpus”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:
npx -y @hawkeyexl/manni meta validate --write-baselineA 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.
Editing the values
Section titled “Editing the values”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.
If the updater is an agent
Section titled “If the updater is an agent”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.