Module 46 · 50 min

Sources That Cannot Change

You can register a source, watch the tool reject a one-line edit to it, and explain why a revision has to arrive as a new file instead of a correction.

Surface
scripts/wikictl.py · inventory and the manifest
Ships to your plugin
scripts/wikictl.py
Claude Code
v2.1.251
Docs checked
2026-09-02

A wiki page is only worth trusting if you can get back to what it was compiled from. That means the source has to still be the thing it was when somebody read it, and the ordinary way of working with files gives you no way to know whether it is.

You edited that vendor spec last March to fix a typo. Or you replaced it with a newer version and kept the filename. Either way the page that cites it now cites something that no longer exists, and nothing anywhere says so.

The fix in this plugin is one rule with teeth. raw/ is append-only: once a file is registered, its bytes are frozen, and the tool reports any change as a violation rather than accepting it as an update.

A hash turns “did this change?” into arithmetic

A hash is a function that reads a file and produces a short fixed-length string, here 64 hexadecimal characters, using SHA-256. The same bytes always produce the same string, and any difference in the bytes produces a completely different one. It is not encryption and it does not hide anything. Think of it as a fingerprint you can compare.

Let’s put a file in raw/ and ask what the tool sees:

printf '# Managed HSM notes\n\nKeys never leave the module.\n' > raw/hsm-notes.md
python3 scripts/wikictl.py inventory
{
  "changed": [],
  "deleted": [],
  "new": [
    {
      "path": "raw/hsm-notes.md",
      "sha256": "e21c800f6b8565b46d05237cbdd804889278e96d42fc5776b43c514e93845b28",
      "size": 50
    }
  ],
  "policy": "raw_append_only_git_tracked",
  "unchanged": 0,
  "untracked_raw": ["raw/hsm-notes.md"]
}

Exit 1, because there are findings. The file is in new, meaning it exists on disk but nothing has been recorded about it. It is also in untracked_raw, which is a separate fact: Git has not been told about it either. Two different kinds of “unknown”, reported separately, because they get fixed by two different things.

new is not a problem to fix, it is a queue. A file sits in new until somebody decides it is a source worth keeping, and that decision is a separate command:

python3 scripts/wikictl.py inventory --register raw/hsm-notes.md
{
  "registered": [
    {
      "path": "raw/hsm-notes.md",
      "registered_at": "2026-09-02T13:01:09Z",
      "sha256": "e21c800f6b8565b46d05237cbdd804889278e96d42fc5776b43c514e93845b28",
      "size": 50,
      "source_id": "src_660bd5986ecaf88dbf95",
      "status": "active",
      "superseded_by": null
    }
  ],
  "unchanged": 1
}

Now the file has an identity. source_id_for (line 163) builds src_660bd5986ecaf88dbf95 from the relative path and the digest together, so the same bytes at the same path always get the same ID, and moving a file changes its ID. status: "active" and superseded_by: null are lifecycle fields that stay this way until module 47 gives you a reason to change them.

The manifest is a log, not a database

wikidemo/
├─ .llm-wiki/
└─ raw/
.llm-wiki/manifest.jsonl

JSON Lines: one complete JSON object per line, no wrapping array, no commas between records.

{"path":"raw/hsm-notes.md","registered_at":"2026-09-02T13:01:09Z","schema_version":1,"sha256":"e21c800f6b8565b46d05237cbdd804889278e96d42fc5776b43c514e93845b28","size":50,"source_id":"src_660bd5986ecaf88dbf95","status":"active","superseded_by":null}
The manifest after one registration. Read the format first: one object per line is what makes this file mergeable.

The file extension is .jsonl, JSON Lines, and the reason is Git. This is the kind of choice that looks arbitrary until the day it saves you. A single JSON array holding every record would rewrite its own punctuation every time a record is added, so two people registering different sources on different branches would collide on the same lines. One object per line means adding a source appends a line and touches nothing else, and a merge conflict between two additions is a conflict you can read.

The keys are sorted alphabetically on write, which is the other half of the same idea: a record that serialises the same way every time produces a clean diff when one of its fields changes.

Watch it refuse an edit

Here is the behaviour the whole rule exists for, and it is worth running yourself rather than taking my word for it. Append one line to the registered file, the smallest possible change, and ask again:

printf 'appended line\n' >> raw/hsm-notes.md
python3 scripts/wikictl.py inventory
{
  "changed": [
    {
      "actual_sha256": "ce199b94ef6a9fcae501b320441a06c024ba87d15912ea244d22d824778851ae",
      "expected_sha256": "e21c800f6b8565b46d05237cbdd804889278e96d42fc5776b43c514e93845b28",
      "path": "raw/hsm-notes.md",
      "source_id": "src_660bd5986ecaf88dbf95",
      "violation": "raw_is_append_only"
    }
  ],
  "unchanged": 0
}

Exit 1. Both hashes are reported, the expected one from the manifest and the actual one from disk, so you can tell a real edit from a line-ending change by comparing them yourself.

And here is the part that matters most: the manifest was not updated. The tool does not accept the new bytes. That refusal is the whole difference between this and a checksum that quietly re-baselines every time you look at it.

A deleted file lands in deleted the same way, for the same reason.

Why a revision is a new file

Now we need to deal with the objection, because the rule sounds inconvenient until you follow the alternative through. Suppose the tool did accept the edit and update the hash. Now a wiki page cites src_660b... and claims a fact. Somebody reads the page, follows it to the source, and the source says something else, because it was updated last month. Nothing is broken from the tool’s point of view. Every hash matches. The page is wrong and there is no signal anywhere that it might be.

Freezing the bytes turns that silent wrongness into a visible event. A revised document arrives as a new file with a new ID, the old one stays exactly as the page described it, and module 47 gives you the command that connects the two.

That is it for the evidence layer. Four buckets, one hash comparison, and a refusal.

What the guard is not

Module 49 adds a hook that blocks writes into raw/, and it is worth saying now what that hook is for. The README puts it plainly: “The shell hook is intentionally not presented as a security sandbox. Complex shell indirection can evade static command inspection. The real integrity layers are the manifest hash comparison and Git history.” The hook catches the honest mistake. inventory catches everything, including the mistake that got past the hook, because it compares bytes rather than inspecting intentions.

Ships to your plugin

Add inventory to scripts/wikictl.py. It walks the configured raw/ directory, hashes every file with hashlib.sha256 read in chunks rather than all at once, loads .llm-wiki/manifest.jsonl, and sorts every file into one of four buckets: new, changed, deleted, or counted in unchanged. Give it a —register PATH flag that appends one manifest line for one file and refuses to register anything already registered. Then prove the refusal: register a file, append a single newline to it, run inventory again, and read both hashes in the changed entry. If your tool updated the manifest instead of reporting the violation, you built a checksum, not an append-only rule.

The mistake most people make first

Registering everything in raw/ in one pass, usually with a loop over the directory. It feels like the same operation done in bulk, and it is not. Registration is the moment a file becomes evidence that a page may cite, and doing forty of them at once means nobody looked at any of them. What you get is a manifest full of half-finished drafts, screenshots, and a file somebody dropped in by accident, all now frozen and all now citable. The symptom arrives weeks later as a page whose provenance points at raw/untitled-3.md. The plugin’s own ingest command draws the line in three words, “Do not batch sources”, and the reason is that the decision cannot be delegated to a for loop.

Does this travel?

Entirely, and it predates all of this. Content-addressed storage is how Git itself works: a blob’s name is its hash, so a changed file is a different object rather than a mutated one. What this plugin adds is a policy layer on top, naming the change a violation instead of a new version. Any harness, any language, no dependency beyond a hash function. If you take one thing from this track into a project that never touches Claude Code, take this one.

Check yourself

  1. inventory reports a file in both new and untracked_raw. What are the two different facts being reported, and which one does --register fix?
  2. A source file is edited and inventory reports it under changed. Why does the tool report both expected_sha256 and actual_sha256 rather than only saying that the file changed?
  3. The manifest is JSON Lines rather than a JSON array. Describe the situation where that choice pays, in terms of two people working on branches.