Skill v1.0.0
Automated scan100/100version: "1.0.0" name: evidence-check description: | Verify that the evidence ledger's content anchors still resolve — a missing or ambiguous unit fails the build, changed content demands re-verification. Use when: checking ledger health, before merging spec-driven work, wiring the check into CI, or after large refactors. NOT for: judging whether the code is CORRECT — this checks that the evidence still points somewhere, not that the claim is still true.
evidence-check — does the ledger still point at what it claims?
Specs rot silently: code moves, the ledger's coordinates keep claiming grounds that are no longer there, and the next reader trusts them. This skill makes that rot mechanical to catch — the same way a broken test catches a regression.
A coordinate names content, never a position
path#major the enclosing unitpath#major>minor ... narrowed to the place a claim is about
Both carry a hash: path#major@3f9a2c1b, path#major>minor@a71b0e42.
A coordinate's job is to put a reader in the right logic, not to pin a line. So the address is the enclosing unit, which is stable against every edit that does not change what the unit is.
| Level | What it is | Where it comes from | |
|---|---|---|---|
| Major | a function or class for code, a heading path for a document | ast for .py; otherwise the declaration rule below; ## A / ### B for markdown | |
| Minor, optional | the statement the claim is actually about | the name it references (ast for .py), or a quoted line as a last resort |
| CLAUSE | `hooks/routing.py#parse@3f9a2c1b` | ... || CLAUSE | `agents/smith.md#"## Boundaries"@a71b0e42` | ... || CLAUSE | `hooks/gate.py#run>overlaps@5d1e7c04` | ... |
Escape a pipe inside a quoted anchor as \|, or the row splits the table it lives in, and a double quote as \", or the coordinate does not parse and nothing checks it.
The rule that decides everything else
An anchor degrades to DRIFTED, never to BROKEN.
The two cost different things. BROKEN says I cannot find it, go edit the ledger — the bookkeeping this design exists to remove. DRIFTED says it changed, go re-read the claim — the work the ledger exists for.
So the minor level narrows what is hashed and never decides whether the row resolves. A minor anchor that stopped matching, or that now matches several places, means that place changed: the row widens to its major unit and reports DRIFTED. Only the major level can be BROKEN.
That is how specific and not strict stop fighting. Precision buys a smaller hash and a row that says what it is about; it never buys a new way to fail.
The minor level is an escape hatch, not a habit
The default is that a row cites a symbol and the hash covers the whole symbol. If a function changes, re-reading a claim about that function is a conservative and honest signal rather than a false alarm — re-reading a row is cheap, and being strict enough to MISS is not.
The cost, on the record: the most-cited symbols in this plugin's own ledger carry four rows each, so an edit to one means re-reading four claims.
Reach for a minor anchor only where a unit is large enough that whole-unit hashing has been MEASURED to drift rows on unrelated edits — never where it merely looks as though it might. The instinct is to reach for precision, and that instinct is what makes a ledger expensive.
A document anchor is a heading path
agents/smith.md#"## Boundaries", never a sentence. A sentence breaks on any rewording, which is a BROKEN and a ledger edit; a heading is the document's own structure and survives the prose beneath it being rewritten, while the hash still reports that prose changing. Where one heading repeats, name its parent: "## Verify / ### Scope".
Resolving a unit without a parser
ast is an exactness upgrade for .py, not the only road — most projects adopting this are mostly code that is not Python.
The generic rule needs no parser and no dependency: the name followed by `(`, `{`, `=` or `:`, with only declaration keywords before it, then the block to the next line at the same or lower indentation. That closes a suite in an indentation language and lands on the closing brace in a brace language, because the brace sits at the declaration's own indent.
= is there because a module-level constant is a unit too, and a common one to cite. The colon is stricter — it declares only when the name opens the line — because if v not in NAME: also ends in one, and without that a row citing a constant reads BROKEN for a use somewhere else in the file. A statement keyword before the name — return render(y);, await render(y) — makes the line a use for the same reason: it is the commonest shape in every brace language, and reading it as a second declaration made an ordinary one-declaration-one-call file BROKEN-ambiguous. A line with nothing at all before the name whose statement ends — render(1); — is a call on structure rather than on vocabulary, and is refused whatever the keyword list says.
Swift, Kotlin, Go, Ruby and Lua end no statement with a semicolon, so that guard never reached them. A line with nothing before the name, an opening paren, and a span of ONE line is treated as uncertain in every language, and the span is what bounds it: render() { opens a block and stays a declaration the rule is sure of, while render(y) alone does not.
The rule reports how sure it is rather than being asked to be right. Where the certain candidates would leave the set empty, the uncertain ones come back — a C# public new void Render(int x) and a Swift case loading(String) are declarations whose modifiers are statement keywords elsewhere — and the answer is marked as having survived only that way. What the marking buys is that nothing downstream has to tell the two apart. The check accepts such a place only where its content reconstructs the row's recorded hash, and otherwise treats the unit as GONE: BROKEN with the repo-wide scan naming the destination, which is the answer ast already gives .py. --reverify refuses to write onto such a place at all, because it is the command that MAKES the hash and has none to compare against. --migrate answers the same way, with one exception it can prove: where the old stamp's commit holds the cited lines unchanged, the person's own line numbers vouch for that place and the row migrates.
A row citing such a unit is still written by hand. The check names the place and the hash it holds — 1-2@a1b2c3d4 — so recording it is a copy rather than a computation somebody has to do themselves.
Where it cannot resolve a unit, that is BROKEN and a person looks. Loud and honest beats a per-language parser nobody maintains.
Marker comments in the source are not the mechanism
An adopting project will ask whether to tag cited places with a marker comment so the checker can find them. No, on four grounds:
- ownership — the ledger is this plugin's artifact and the code is the
project's. An anchor scheme requiring writes into the observed source inverts that.
- decay — a teammate deletes or duplicates a marker they do not
recognise, silently.
- coverage — markers only cover marked places, while the ledger's value is
citing arbitrary ones, including pre-adoption and vendored code. The derived anchor is needed anyway, so a marker can only ever be an optimisation.
- measured cost — this repository's rider comments (the
RIDERmarker)
carried commit SHAs, a squash orphaned them, and a patch release exists because of it.
The one permitted use is a place with no structure at all — a magic constant in a config, one line inside a large literal — where neither a symbol nor a heading exists to derive from. There a bare identifier comment is the last resort. It carries a name and nothing else: no SHA, no date, no verification state. Verified-ness lives in the ledger only.
Run
evidence-check [ROOT] # on PATH while the plugin is enabledevidence-check --strict .evidence-check --reverify . # after re-reading: rewrite each row's hash
| Flag | Meaning | |
|---|---|---|
--ledger GLOB | ledgers to scan (default seal/ledger.md, seal/ledger/*.md and seal/releases/*.md). A run given this prints which ledgers it did not read, and how to read them | |
--default-repo PATH | migration ledgers cite the ORIGINAL repo with unprefixed paths — resolve them against this checkout | |
--map NAME=PATH | resolve NAME/... prefixed coordinates against another checkout | |
--strict | drift and a malformed coordinate exit 2, the broken-coordinate code, instead of 1. This is the form broad-gate runs | |
--reverify | rewrite every resolvable row's hash to what its anchor holds now — and re-anchor every BROKEN row that exactly one unit reconstructs, path and locator both | |
--migrate | rewrite old path:line rows to path#unit@hash; what it cannot prove is left and named |
Which reader graded your tree
Four readers run this checker over one tree. Three of them read its exit code and grade drift and a malformed coordinate differently, and the command above is the most lenient of those three; the fourth never reaches the exit code at all.
| Reader | Drift is | MALFORMED is | |
|---|---|---|---|
evidence-check ., the command this page documents | exit 1, the lenient reading | exit 1, the lenient reading | |
CI's ledger job, which runs the same script and adds no flag of its own | exit 1, rendered as a ::warning:: — the job still passes | exit 1, rendered as the same ::warning:: — the job still passes | |
broad-gate | exit 2. It runs this same check with --strict, and the branch comes back NOT SEALED | exit 2, for the same reason | |
hooks/evidence-advisor.py | not reported at all. It imports this module in process rather than running the script, so it never reaches the exit code — and a line that prints on every commit is a line people learn to skip | printed as a block on the commit, never an exit code: the advisor keeps MALFORMED among the rows it names and drops drift |
All four are right about the tree they are looking at. A branch mid-flight legitimately drifts, and the gate runs once at the end over a tree nobody is still editing — so the disagreement is the design and not a defect. What was the defect is that nobody said so: a session that ran the documented command and read exit 1 had no way to learn that the run which decides reads the same tree as a refusal.
A malformed coordinate is not a branch mid-flight, and it is graded like drift for a different reason: the repository owner's answer of 2026-09-26, which keeps a release from starting to refuse rows in a repository's lenient run. The readers that decide — broad-gate and the vendored CI template, which both pass --strict — still refuse it at exit 2. OLD-FORMAT, the other coordinate nothing can parse, stays exit 2 under both readings.
So a lenient run says it. Where a check run's answer is exit 1 and only there, the check prints which reading you took and what broad-gate would say instead. Exit 0 and exit 2 print nothing extra, because every reader grades those alike. --migrate and --reverify are writers, not readings of drift: each returns 1 for the rows or ledgers it could not rewrite, names them, and carries no notice (#354).
A narrowed run says what it did not read
--ledger is right for one of this tool's two jobs and blinding for the other. Narrowing is what keeps --reverify off a row whose claim is false and belongs to somebody else; carried into reading, it hides every row the branch broke in a ledger it does not own. So a --ledger run opens with the files it skipped, named one per line:
--ledger narrowed this run — 1 ledger this repository carries was not read:seal/ledger.mdrun without --ledger to read them; a branch falsifies rows in ledgers it doesnot own, and those are the rows with the longest reach
It prints before anything is read, so it is above the totals rather than behind them, and it prints even when the pattern matched nothing at all — otherwise a typo in the glob reports no evidence ledgers found, which is the sentence a repository with no ledger gets. A run that narrowed to exactly what the defaults would have opened skips nothing and says nothing.
The line is a report, not a second pass: nothing in a skipped ledger is opened, hashed or re-stamped.
Two names for one file are one ledger. What was read and what the defaults would have opened are matched by inode (st_dev/st_ino), so a case variant on a case-insensitive filesystem, a hard link and a symlink all count as read. Comparing spellings of the path instead put a platform inside the answer: --ledger SEAL/ledger.md read the ledger and then listed it as unread, which is a notice naming a file it had just opened.
Two ways out of the fold, not one, and the second was missing here until a review round asked. os.stat raising is the obvious one — a file that vanishes between the glob and the check, or a path that cannot be traversed. The other is an inode of zero, which raises nothing: Python's contract is "if non-zero, uniquely identifies the file", and CPython's Windows stat leaves both fields 0 when it cannot open a file. Taken at face value every such file has one identity, so a ledger that WAS read swallows every ledger that was not and the run says nothing. Both ways out fall back to the normalized absolute path, which over-reports rather than under-reports: a ledger is named as skipped rather than passed over in silence.
Measured (#153): one work item's three review rounds and two fix passes all ran the scoped form and all reported ok. The unscoped read at the pull request found fifteen drifted rows and one broken claim, every one in a file the branch had touched.
Verdicts and what to do
| Verdict | Meaning | Action | |
|---|---|---|---|
BROKEN (exit 2) | the MAJOR unit — or its whole file — is not there, or the unit is there more than once | fix the coordinate now. Where the content still exists the line names the destination, graded by proof: identical content at <where> (renamed?/moved?) is content identity across a repo-wide scan and --reverify acts on it; same name at <path> (content differs) is a labelled fact only; several matches are counted, never named | |
OLD-FORMAT (exit 2, --strict or not) | an old path:line row from before content anchoring, which nothing measures any more | run evidence-check --migrate . — a red build naming the migrator beats a green build checking nothing | |
MALFORMED (exit 1; 2 under --strict, which is what broad-gate passes) | a row's Code grounds cell holds a coordinate that does not parse — a placeholder or short hash, no path, a bare " inside a quoted locator, a minor anchor that is not quoted — or cites no coordinate at all while the row claims something. Before this verdict such a row entered no count and the totals read clean | write it as path#anchor@hash: a " inside a quoted locator as \", the hash as @00000000 until --reverify fills it. --reverify names the row and leaves it, because which reading of an unparseable coordinate was meant is not the checker's call. Exit 1 here means fix the coordinate, not re-read: the verdict word on the row says which of the two exit 1 is | |
DRIFTED (exit 1; 2 under --strict, which is what broad-gate passes) | the content changed, or a minor anchor's place is gone | re-open it, re-read the claim, then --reverify. This is one of the two verdicts the readers grade differently, MALFORMED being the other — see Which reader graded your tree | |
EXTERNAL (exit 0) | the path resolves in no known checkout, in a repository that has DECLARED cross-repo intent — a parity config, --map, or --default-repo | pass --map/--default-repo, or accept as out of scope. Without such a declaration a missing path is BROKEN instead: a deleted or renamed directory must fail the build, not read as somebody else's repo | |
NOT-IN-TREE (exit 2, records arm) | a record of a work item that has not shipped names a compound backticked identifier that nothing git carries outside seal/specs/ and seal/ledger/ | correct the record, or append · NAME NOT IN TREE on the line where the record means a name the tree does not have (placed before any trailing colon introducing a block). The marker exempts the LINE, not the name | |
UNREADABLE (exit 2, records arm) | a record under a live work item that could not be opened, or a directory the walk could not LIST — a work item's own folder, or seal/ledger/ itself | a record nobody can read is indistinguishable from a record with nothing in it, which is the green build this refuses. The same holds a directory up, where it is worse: an unlistable seal/ledger/ used to read as a repository with no live work item and take the whole arm quiet at exit 0. A directory that is ABSENT is still an empty answer — a repository that has not started is not a broken one | |
OK | the content is what the row recorded — the current line numbers are printed for you to open |
An ambiguous MAJOR unit is BROKEN, loudly, and never a measurement. With two places to look, an OK would be a claim about whichever one the code happened to reach first. An ambiguous minor anchor widens instead — see the rule above.
Re-verifying is recomputing the hash
evidence-check --reverify .
It rewrites the hash of every row whose anchor resolves, and names each one it changed. That is a person saying they have re-read the code, which is why it is a separate command: a check that refreshed what it was checking would report OK for ever. A row whose anchor is gone is left alone — silently renaming its hash would hide the one row somebody has to look at. A MALFORMED row is left too, with a LEFT line naming it and the remedy, and the run exits 1.
A row inside a fence is an example, not a claim
A ledger that explains its own row format shows an example row in a fenced code block, and nobody wrote that row as a claim. So the check, --reverify and --migrate all skip every line of a fenced block that closes: the example is not reported, and neither writer changes a byte of it (#444).
Three things are still read, each because skipping it would be silent:
- A fence that never closes. It runs to the end of the file, and reading
nothing from there on would pass a broken row on a file whose author made a mistake. Its rows are checked as rows.
- An HTML comment. A commented-out row is a claim somebody parked, and
dropping it is the silent direction.
- An indented code block. Only a fence is a quotation here.
What counts as a fence is CommonMark's rule, and the one the ledger and record readers share: at most three spaces of indentation, three or more backticks or tildes, a backtick opener whose info string holds no backtick, and a closer of the same character, at least as long, with nothing after it. Some readers elsewhere in the plugin still keep a rule of their own, and #584 tracks them.
A ledger row you mean as a claim does not belong inside a fence. Before this rule, one there was checked. Now it is not, and nothing says so.
What the region is
| Anchor | Region | |
|---|---|---|
a symbol in .py | the whole def/class span, decorators included — a decorator carries behaviour | |
| a symbol elsewhere | the declaration line to the next line at its indent or lower, so a closing brace ends it | |
| a markdown heading path | down to the next heading at its level or above, which is what a reader means by a section | |
| a minor anchor | the statement it names, capped so a claim cannot quietly grow to a whole unit | |
| any other line | the contiguous run of non-blank lines it sits in — a paragraph, a table, a block of code |
The heading rule is markdown-only. # opens a comment in Python, shell and YAML, and reading one as a heading made a 23-line comment block resolve to its first line alone.
Indentation is content. Trailing whitespace and blank lines are normalised away, so a reformat is not a change; leading whitespace is not, because in Python a dedent moves a statement out of the block it belonged to, and a checker that shrugged at that would go quiet exactly where the edit matters.
One fragment per work item
A work item's rows go in seal/ledger/<work-item-id>.md, which the default globs already read. Two branches never queue at one file, because no two work items share an id. The release that ships the work item folds its fragment into the ledger and removes the file — this plugin's own repository folds into one file per release, seal/releases/<X.Y.Z>.md, which the default globs read too. A row is checked against the code it cites wherever it sits, so the fold changes no row's status. The ok total counts a (coordinate, hash) pair once per file, so a fold can change the count.
A row citing a range that spans several definitions becomes several coordinates, one per definition. That is not a loss: it is the row saying which pieces of code it is actually about.
correction-check — a correction a merge dropped
The fragment rule has one exception and the exception is the whole of this problem: a branch that removes or edits the code an existing ledger row cites, or makes what the row claims false, keeps that claim true in the file the row is in. So two branches in one release correct rows of one file, the file conflicts, and resolving it by taking a side reverts whatever the other side had corrected.
This check cannot see that, and neither can anything else here. A row reverted to a superseded state is byte-identical to a row nobody touched: there is no marker on it, the anchors resolve, and the hash is correct for the restored text. Run afterwards, --reverify re-stamps it — writing somebody read this over a claim that had been read, found false and repaired.
So a second command reads what the corrections carry in their prose:
correction-check --range origin/<base>...HEAD
It walks every merge commit in the range, reads seal/ledger.md, every seal/ledger/*.md fragment and every seal/releases/*.md file at the merge, at both parents and at the merge base, and names every Corrected <date> or Re-read <date> marker a parent carried that the result does not — while the row carrying it still stands. A marker that went with its row is REMOVED and correct, and a marker a parent deleted relative to the base is that parent's decision rather than the merge's. Exit 0 when nothing was dropped, 1 with each loss named, 2 for a range that does not resolve.
Its moment is the pull request, and it has no other. A feature branch squashes into its release branch, so the merges it reads stop existing the moment the branch lands. The hygiene workflow runs it on every pull request into a release branch for that reason, and a repository with no merges in the range gets one line saying so and exit 0.
It reports the loss; it does not prevent it. Reading both sides of a hunk is a person's act, and a merge driver for the file would have to understand what a row claims — which is the judgment this whole ledger is built around a person making.
The records arm — what a work item's records say about the tree
A ledger row is a claim about the tree that something reads. A record — spec.md, plan.md, overview.md, rounds/round-N.md, phases/phase-N.md — states the same kind of thing and nothing read it (#190). It names a unit, or stamps one, and the next commit moves what it named.
Every run reads them, under its own heading and with its own counts, and no flag turns it on:
records — what unreleased work items state about the treeNOT-IN-TREE seal/specs/1780000000-x/plan.md:14 `gone_helper` — nothing outside …1 work item read · 38 unread · 206 names read · 0 stamps read · 1 refused · 0 drifted · 0 external
Whose records are read is decided by the ledger fragment. A work item with seal/ledger/<id>.md still on disk has not shipped; the release folds that file away, so the boundary is a file the fold already removes and there is nothing else to keep true. A shipped work item's records are records of a moment — a plan from two releases ago proposing a helper that was built under another name is correct as history — and refusing those would be refusing the past.
`N unread` is the other half of that boundary, because has a fragment answers is live and its converse does not: a work item that has not written its rows yet is skipped, and used to be skipped in silence. 0 names read and exit 0 says the same thing for every record is clean and no record was opened, so the count is on the line either way.
What counts as a claim. A backticked identifier carrying an underscore, and an anchor stamp path#unit@hash resolved exactly as a ledger row's is. A single word in backticks is prose far more often than it is a unit. A FENCED line is a quotation — a paste-ready fix is code the tree does not have yet — and an HTML comment that begins a line is an aside; neither is read.
A comment is an aside only where it begins a line, or begins what is left of a line after a -->. One that opens part-way along text is read with the text, so a name inside it is read and can be refused. Put the marker on that line, or start the comment on a line of its own. Treating a mid-line comment as an aside would need a scanner that knows code spans, because records quote <!-- inside backticks all the time, and each such quotation would otherwise hide every claim up to the next --> without a word (#220).
Both of those are REGIONS, and each runs to its own end. A comment is an aside to its -->, so a template's two-line comment is an aside on both lines. The `-->` ends the aside where it stands, not at the end of its line: a name written after it is read, and a <!-- right after it opens an aside again. A fence runs to a close of the same character that is at least as long as the opener and carries nothing after it, the rule the ledger and record readers share. So a ~~~ quoted inside a ``-block does not end the quotation, and neither does a `` ` ``` quoted inside a
never closes reads as a malformed record rather than as a quotation ofeverything left: its lines are read as claims, because an author's missingbackticks must not be the thing that makes the rest of a record pass insilence. A comment the record never closes takes the same answer — its linesare read, because a missing `-->` is the same mistake one region over, anduntil #217 it silenced every claim under it while the arm said nothing. The`NAME NOT IN TREE` marker still exempts any line it sits on, held or not.**What counts as the tree.** Every identifier-shaped token in every file thewalk reaches, prose and file names included, outside `seal/specs/` and`seal/ledger/`. `seal/ledger.md` and `seal/releases/` are inside it: a shippedrow's names are the tree's. Caches, build output and `.git` are skipped,because a `__pycache__` carries the identifiers of a module the tree has sincelost.**An untracked or `.gitignore`d file still counts**, and that is a knownhole: a scratch note holding a name silences a refusal with no committedbyte. Closing it means asking git what it carries, and this checker calls gitfor nothing outside `--migrate` — see *A row carries no line number and nocommit* in `README.md`. What the hole costs is bounded in the safe direction:CI reads a clean checkout, where the untracked file does not exist, so CI isthe stricter reader and the local run is the lenient one.**Grading follows the ledger's**, with one difference. `EXTERNAL` is exit 0and `DRIFTED` in a record does not fail the run: a live work item's branch isediting the very units its records stamp, so failing on drift would be red byconstruction. A name the tree does not carry has no such excuse — it isabsent, or the record is wrong, and the marker is one comment away.## Known limits- A missing file is `BROKEN` with the same graded scan hints as a missingsymbol — a renamed file or directory is findable by content. `EXTERNAL`needs declared cross-repo intent, and where intent is declared but a row'sfile is in none of the named checkouts, the scan stays OFF: searching thisrepository for a row that may cite the other one manufactures evidence.Intent is read per ROW where it can be: a row whose prefix is not among the`--map` names is a local row and keeps its scan. What cannot be read per rowis an UNPREFIXED row in a repository declaring `seal/parity.md` or`--default-repo` — it may be citing the original, and nothing in thecoordinate says which. Such a row loses the scan for any move, not just fora renamed directory: no `(moved?)` hint and no `--reverify` heal, so it isfixed by `--map` or by hand.- `DRIFTED` means "someone must re-read this", not "the claim is wrong".- A place the rule is unsure of — a C# `new`, a Swift `case`, a one-linebare-name declaration — is never re-anchored by `--reverify`, and ismigrated only where the old stamp vouches for the cited lines. Where itscontent changed in place and no destination is provable, both commands leavethe row and print the hash to record by hand. Accepting it instead is how acall site left behind by a move becomes the row's permanent anchor.- Every row the check calls `BROKEN`, `DRIFTED` or `MALFORMED` gets a lineback from `--reverify`, whether or not it could heal it. Silence there readsas a heal that happened.- `MALFORMED` reads one cell of a row: the column headed `Code grounds`, orthe second cell of a row under no header, which is every fragment row. Atable whose header names no such column is not read, so a ledger thatrenamed the column takes that table out of the verdict, and a coordinatethat does not parse in a Notes or Verified-behavior cell is not named.Scanning every cell was measured on this plugin's own ledgers and refusedseven correct things in eight — the template's notation row, `#unit@hash`shorthand, a quoted example of the bug — which is why the arm keys on thetemplate's column name. A claim row whose `Code grounds` cell is empty isnot read either: a row citing nothing is named only when the cell holdstext.- A nested `def` is anchored by its qualified name — `outer.inner` — and theshort name alone resolves to nothing. Such a row reads `BROKEN` with thequalified unit named on the same line, and `--reverify` re-anchors it.- Reconstruction proves identity of content, not history. Deleting a unitthat has a boilerplate twin — an `__init__`, a trivial getter, a thinwrapper, and most readily of all a one-line constant, where the namesubstitution leaves nothing but the value — reads as `renamed?` pointing atthe twin, and `--reverify` would re-anchor to it. Deleting `TIMEOUT = 10`beside an unrelated `RETRIES = 10` is the cheapest way to see it. The line says *identical content*, which is the whole ofwhat was proven; the deletion is yours to spot in the diff.- Renaming a symbol reads as `BROKEN` — but where exactly one unitreconstructs the recorded content, the line names it and `--reverify` fixesthe row. The proof substitutes the candidate's name with the row's locatorand compares against the RECORDED hash, so content that changed AND movedmatches nothing and stays a plain BROKEN.- The rename scan is bounded so the clean path stays fast: same-extensionfiles only, files over 256 KB skipped, and past 200 candidate files itdegrades to the row's own file and says so on the line. Measured2026-09-23, CLI wall time, median of five, Python 3.12 on macOS, onone-line candidate files: one BROKEN row against 200 files ~72 ms, pastthe cap ~70 ms, against ~67 ms for an empty ledger. The interpreter and theimport are nearly all of it.- A name match with different content never fixes anything — `main`,`resolve` and `check` collide across files as a matter of course.- The generic unit rule stops AT a closing brace rather than including it. Thebrace carries no claim, and a language-aware rule for what closes a block isthe per-language parser this deliberately does not have.## Migrating a pre-anchor ledger**The default is that nobody runs anything**: at the first session start afterupdating, an opted-in repository's ledger migrates itself and prints one lineending *review the diff and commit*. Once per repository; never over anuncommitted ledger file — the dirty check covers exactly the files themigration would rewrite, and a dirty one is skipped with one line and retriedat the next clean session start. The write is licensed by ownership (theledger is the plugin's artifact) and bounded by visibility: deterministic,idempotent, all-or-nothing per row, old text in git history.A recorded line number is trusted only as far as it can be vouched for. Wheregit can produce the file at a row's old stamp, a cited range whose contentchanged since that commit is LEFT rather than rewritten onto whatever sits atthose lines now. Where the proof is unavailable — no git, no stamp, a commita squash orphaned — the row migrates against the current tree alone and thesummary says how many did, so those rows are reviewed in the diff rather thanassumed.For CI, for a skipped tree, or by hand:
evidence-check --migrate .
One command, once. Each `path:line` row is resolved againstthe current tree to its enclosing unit and rewritten as `path#unit@hash`; thecommit stamp drops and the date stays. The run prints the same faithfulnessreport this repository's own 51-coordinate migration was held to: how manyconverted, and every row left named with why — a line past the end of itsfile, a file that is gone, a range no single unit contains. A left row keepsfailing the plain check as `OLD-FORMAT`, so nothing is silently dropped, andrunning the command twice is a no-op.## CI`/specseal:evidence-ci` does the wiring: it vendors `scripts/evidence_check.py`to `tools/` and writes `.github/workflows/evidence-check.yml`, resolving theplugin's own path so nobody has to know where it is installed. Re-running itdiffs the vendored copy against the current one.Vendoring over fetch-at-run keeps CI deterministic and offline-safe, and putsthe checker in the diff where a reviewer can see it change.