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, or leave fields out for the
merge-safe default. 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.
Merge-safe by default
Section titled “Merge-safe by default”A stamp is written in a pull request and judged on the base branch after
the merge. A squash merge makes one new commit, authored by whoever merged
and dated at the merge. GitHub appends a Co-authored-by trailer for each
author of the branch. Some fields stay current through that commit, and
some cannot.
| Field | Current after a squash? | Why |
|---|---|---|
owner | yes | It comes from CODEOWNERS, and no commit is involved. |
created, last-updated | yes | The squash commit carries the stamp, on the page or in its manifest. |
provenance | yes | The squash commit carries the stamped ranges. |
authors | no | The person who merged and the appended trailers join the list. |
reviewed-by, last-reviewed | no | They come from merged pull requests only, so a branch has nothing to stamp. |
verified-against, and any command field | no | The release commit after the merge changes the evidence. |
A derive: block with no fields manages the four merge-safe fields. Each
is managed only on a page whose schemas claim it as a top-level property.
A page without the stewardship
vocabulary is never held
to an owner. Stewardship and ai-context are in the built-in default set, so a
page the defaults judge manages all four.
That reach includes test fixtures and drafts validated from the same root.
collections keeps derive on the pages it was set up for. A file in none of
the named collections is neither stamped nor compared:
collections: - name: site paths: ["docs/**/*.md"]
meta: derive: collections: [site] sources: [git, codeowners]A name no collection declares is refused, exit 2:
manni: manni.config.yaml: meta.derive.collections names "sit", which no collection declares.Two rules keep a stamp written on a branch current after the squash.
- A stamp is read where it lives. The commit’s copy of the page is
read, and so is the manifest that owns
createdorlast-updated. A date kept in{page}.meta.yamlsurvives a squash as one on the page does. - A stamped commit accounts for all of its lines. When the commit that
last touched a line carries the page’s
provenancestamp, a line outside every stamped range has no machine. A trailer the squash appends cannot turn a human line into a machine’s.
One case still reads stale once. An edit made on a day the page already
carries that day’s last-updated leaves the stamp unchanged. Merged on a
later day, the squash changes the body and keeps the stamp. Git cannot tell
that from an edit whose author forgot to restamp, so the merge date stands.
Running manni meta derive on the base branch clears it.
fields: [] manages nothing. A written fields list is managed exactly as
written, safe or not. A team that stamps authors or the review fields
after the merge lists them there, and runs manni meta derive on its base
branch.
When no page’s schemas claim a merge-safe field, manni meta derive exits
2 rather than stamping nothing.
manni: nothing to derive: no page's schemas claim a merge-safe field (owner, created, last-updated, provenance); set derive.fields or pass --fieldsWhen a page’s schemas cannot be read, what they claim is unknown, so the message names that page instead:
manni: nothing to derive: the schemas of docs/install.md could not be read, so what they claim is unknown; fix them or pass --fieldsRun 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 derive covers 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.
collections: - name: pages paths: ["docs/**/*.md"]
meta: 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@v4 with: command: meta validate env: GH_TOKEN: ${{ github.token }}The step 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@v4 with: command: meta validate 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@v7 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, leave it out of a written derive.fields list. 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.