Module 47 · 50 min

Provenance in the Page

You can write a page that carries its own evidence, mark one source as replacing another, and predict exactly which pages `stale` will name afterwards.

Surface
wiki/*.md frontmatter · supersede and stale
Ships to your plugin
scripts/wikictl.py
Claude Code
v2.1.251
Docs checked
2026-09-02

Module 46 froze the sources. That gets you half of an audit trail: the evidence is still what it was. The other half is the link from a conclusion back to the evidence it rests on, and that link has to live somewhere a tool can read without understanding the prose around it.

It lives in the page, at the top, in the block between two lines of three dashes. That block is called frontmatter. Here is a complete page, and it is shorter than you probably expect:

---
title: Managed HSM
type: system
last_linted: 2026-09-02
sources:
  - id: src_660bd5986ecaf88dbf95
    hash: e21c800f6b8565b46d05237cbdd804889278e96d42fc5776b43c514e93845b28
---

Keys never leave the module. See [[index]].

Four fields, and the fourth is the one that does the work. sources lists the ID of every raw file this page was compiled from, and beside each ID the exact hash that file had when it was read. The page carries its own receipt.

Two IDs where one would have done

Recording the hash as well as the ID looks redundant, since the manifest already holds the hash for that ID. I thought so too, at first. It is doing a different job.

The ID says which source. The hash says which version of it the author actually read. With both written into the page, a script can ask a question no single field could answer alone: does the hash this page recorded still match the hash the manifest holds for that ID?

When it does not, lint reports it under source_hash_mismatches. That is an integrity error, meaning something is inconsistent in a way that should not be possible, and it is a different finding from the page being out of date.

Keeping those two apart is worth the extra field. A page whose evidence has been tampered with and a page whose evidence has been superseded need different responses from you, and a tool that folds them into one “needs attention” bucket has thrown away the distinction at the moment you need it.

wikidemo/
├─ wiki/
└─ .llm-wiki/
wiki/managed-hsm.md

A complete knowledge page. Four frontmatter fields, then prose and a wikilink.

---
title: Managed HSM
type: system
last_linted: 2026-09-02
sources:
- id: src_660bd5986ecaf88dbf95
  hash: e21c800f6b8565b46d05237cbdd804889278e96d42fc5776b43c514e93845b28
---

Keys never leave the module. See [[index]].
One compiled page and the manifest line it points at. Read the page first, then follow its two source fields into the manifest.

wiki/managed-hsm.md is a whole page. The prose is one sentence, and that is a fair ratio for this plugin: pages are meant to be small and specific rather than long and general.

The parser reads four fields and stops

Now, you might expect a YAML library here. There is not one, and the README says why: “The parser is intentionally constrained to this schema so the runtime stays Python-stdlib-only. Do not expand it into a general YAML parser.”

parse_frontmatter at line 480 reads title, type, last_linted, and a sources list of id and hash pairs. Anything else in the block is ignored rather than rejected. Two things follow from that, and both are the kind of thing you find out at the wrong moment if nobody tells you.

The first is that the plugin installs with no dependencies at all, on any machine with Python 3. That is the whole benefit and it is a real one.

The second is that clever YAML fails quietly. Anchors, multi-line block scalars, flow mappings, and quoted keys are not understood. A field written in a form this parser does not read is not an error; it is a field that is not there. index.md and log.md are allowed to carry sources: [], and that empty list is a value the parser does understand.

Supersession is a command, never an inference

Next up is the piece that connects an old source to the thing that replaced it, and this is where most people expect the tool to be clever.

Two documents about the same subject, one newer. Every instinct says the tool should notice. This one refuses, and the rule is written into SKILL.md in five words: “Similarity never implies supersession.”

Replacing one source with another is something you say out loud:

python3 scripts/wikictl.py supersede src_660bd5986ecaf88dbf95 src_0ad4a3008ac4514d40e5
{
  "changed": true,
  "source_id": "src_660bd5986ecaf88dbf95",
  "superseded_by": "src_0ad4a3008ac4514d40e5"
}

Exit 0, because this is bookkeeping you asked for rather than a finding. The old source’s manifest record now carries superseded_by pointing at the new one.

Notice that nothing about any wiki page changed. No file was rewritten. What changed is that a question now has a different answer:

python3 scripts/wikictl.py stale
{
  "pages": [
    {
      "page": "wiki/managed-hsm.md",
      "sources": [
        { "source_id": "src_660bd5986ecaf88dbf95", "superseded_by": "src_0ad4a3008ac4514d40e5" }
      ]
    }
  ],
  "stale_definition": "page_cites_explicitly_superseded_source"
}

Exit 1. The tool emits its own definition in the output, page_cites_explicitly_superseded_source, which is the rule in one field: a page is stale when it cites a source somebody explicitly marked as replaced. Not when it is old. Not when it looks similar to a newer page. Not when it has not been touched in a year.

That definition is narrow, and the narrowness is what makes the result actionable. A staleness signal that fires on age gives you a list you learn to ignore by the third week. This one fires only when a human made a claim about two documents, so every page it names has a specific piece of work attached: read the new source, reconcile the page, update the provenance.

The page is…Reported byUnderWhat it means
citing a source marked supersededstalepagesRe-read the new source and reconcile
citing a hash the manifest disagrees withlintsource_hash_mismatchesSomething is inconsistent; investigate before editing
citing an ID that is not in the manifestlintunknown_sourcesThe provenance is wrong or the source was never registered
missing title, type, or sourceslintmissing_fieldsThe page is not yet a compiled page

What lint sees in a clean wiki

Let’s see what this looks like all together. Run lint against the single page above, with a review limit of zero so it does no sampling:

python3 scripts/wikictl.py lint --review-limit 0
{
  "archive_candidates": [],
  "broken_links": [],
  "missing_fields": [],
  "missing_frontmatter": [],
  "orphans": [{ "page": "wiki/managed-hsm.md" }],
  "review_policy": "oldest_last_linted_first",
  "semantic_review_candidates": [],
  "source_hash_mismatches": [],
  "unknown_sources": []
}

Exit 1, on a wiki with one valid page and nothing wrong with it. The finding is orphans, and an orphan is a page nothing links to. This page links out to index, and that link resolves, but no page links back at it, so it is unreachable by anyone navigating from the index.

That is the finding you will see most often, and it is worth understanding rather than silencing. A wiki whose pages are all orphans is a folder of files. The index exists so knowledge is reachable, and a page nobody can reach from the index is knowledge you will not find again when you need it.

That is the provenance layer complete. A page that carries its evidence, a command that says one source replaced another, and a query that turns the two into a work list.

Ships to your plugin

Add three things to scripts/wikictl.py. First, parse_frontmatter: find the block between the opening and closing --- lines, read the four fields, and return None for a file that has no block at all so the caller can report missing_frontmatter separately from missing_fields. Second, supersede OLD NEW: check both IDs exist in the manifest, set superseded_by on the old record, and exit 2 rather than 1 if either ID is unknown, since that is a caller error and not a finding. Third, stale: for every page, look up each cited source in the manifest and report the page if any of them carries a superseded_by. Verify it end to end by registering two sources, writing a page citing the first, running stale and getting an empty pages list with exit 0, then running supersede and getting that same page named with exit 1. A staleness rule you have not watched switch from empty to non-empty is a rule you are guessing about.

The mistake most people make first

Copying a page to start a new one and leaving the old sources block at the top. The new page is about something else, cites evidence it was never compiled from, and every deterministic check passes: the ID is real, the hash matches the manifest, the frontmatter has all its fields. lint is clean. stale is empty. Then the source gets superseded, and this page shows up in the stale list next to a genuinely affected one, and you read a source that has nothing to do with the page in front of you. Nothing in this plugin can catch it, because “this citation is about the right subject” is a judgment, not a comparison. The habit that avoids it is to delete the sources block first when you copy a page, and let missing_fields remind you to fill it in properly.

Does this travel?

Frontmatter is portable and every static site generator, note tool, and Markdown editor already reads it, so the provenance survives into tools that know nothing about this plugin. The narrow parser does not travel: point a real YAML library at these pages and it will accept syntax this one silently drops, so a wiki authored against a permissive parser breaks when it comes back here. If you build on this, keep one parser and make it the strict one. What travels best is the discipline underneath, recording both which source and which version, which is the same idea as a lockfile and works anywhere you can write a file next to your data.

Check yourself

  1. A page records hash: abc... for a source whose manifest record says sha256: def.... Which command reports it, under which key, and why is that not the same finding as the page being stale?
  2. supersede exits 0 while stale exits 1 immediately afterwards, with no file edited between them. Explain both codes.
  3. You copy a page and forget to clear its sources block. Name every deterministic check in this module that fails, and say what that tells you about where the responsibility sits.