Writing anchors
An anchor is a content-based claim about the code: “in that repo, in those files, something matches this”. Lines move on the first commit; content survives. Write anchors so that a verify failure means the claim is actually in doubt.
Grammar
One leg per line, an HTML comment directly under the claim’s row in
.multivac/invariants.md — invisible when rendered, greppable, no parallel file to
drift. Verify reads them from the brain’s root *.md, .multivac/*.md and
.multivac/changes/*.md; an anchor inside a code fence, indented four spaces or
in a file in any other directory is skipped without a word, so its claim just
reads as unanchored and a tombstone there blocks nothing:
<!-- @anchor <CLAIM-ID> <repo-key>:<glob> [![<repo-key>:]<glob> ...] /<regex>/[flags] [mode] -->CLAIM-ID is explicit, never inferred from proximity. It is the join key for reporting and for
change close.repo-key is the registry key from
.multivac/config.yml(api), never the directory name (acme-api).*= every declared repo plus the brain itself.glob is a picomatch pattern over repo-relative,
/-separated paths (whatgit ls-filesprints):**crosses directories,{a,b}alternates, dotfiles match. Not shell, not regex —src/*.tsmissessrc/lib/git.ts, so writesrc/**/*.ts.!<glob>excludes, applied after the include. The surviving file set is what gets matched — and what counts toward vacuity.!<repo-key>:<glob>excludes in that repo only. A bare exclusion is repo-relative and bites in every repo the leg evaluates, so under*it exempts the path’s namesake everywhere; qualify it to exempt one repo:<!-- @anchor INV-77 *:**.md !brain:07-rules.md /PIN/ absent -->“no PIN in any markdown, anywhere — except the page in the brain that carries the tombstone”. A
07-rules.mdin another repo is still checked. An exclusion naming an undeclared repo is a parse error naming the key; qualifying an exclusion in a single-repo leg is legal and redundant.Flags:
ionly.mode:
present(default),absent,unique,count=N,each,each!.One include glob per leg. Alternate paths with braces —
api:{src,lib}/**/*.ts— never with a second glob; only exclusions repeat.The whole grammar is one screen away:
mvac help anchor. Before pinning acount=N, dry-run the leg withmvac count '<repo>:<glob> /<regex>/'— it prints the per-file breakdown through verify’s own matcher, so the ratchet is right the first time.
A claim may carry several legs; legs AND together. On failure the claim inherits the worst failing leg’s severity, and verify reports per leg.
Dialect: POSIX ERE, enforced
The one dialect every engine on every machine executes the same way.
\s \b \d \w are rejected at parse time — macOS git grep drops
them silently, which turns a tombstone into a vacuous pass. The error prints
above the claim count, and a parse error exits 1 in every mode:
$ mvac verify
parse .multivac/invariants.md:10 — \s is not POSIX ERE — use [[:space:]]
3 claims · 2 anchored (67%)
...The summary line reads 0 blocking broken · exit 1 · 1 anchor parse errors.
Translate:
| PCRE | POSIX |
|---|---|
\s | [[:space:]] |
\d | [[:digit:]] |
\w | [[:alnum:]_] |
\b | (^|[^[:alnum:]_]) … ([^[:alnum:]_]|$) |
Four more constructs are refused for the same reason — they mean something in
JavaScript and something else, or nothing, to git grep:
| Written | Why it is refused |
|---|---|
(?= (?! (?: | POSIX ERE has no lookaround and no non-capturing groups |
*? +? ?? | POSIX ERE quantifiers are greedy; there is no lazy form |
\1 … \9 | POSIX ERE has no backreferences |
\t \n, any alphabetic escape | POSIX ERE escapes punctuation only |
And the mistake that used to compile: a character class needs BOTH pairs of
brackets. [[:digit:]] is the class; [:digit:] is a bracket expression whose
members are :, d, i, g, t — so PIN[:digit:] used to become
PIN0-9, matching that literal text and never PIN4. It is refused now, in
git grep’s own words: character class syntax is [[:digit:]], not
[:digit:].
Matching rules you must know
.sqlfiles match per statement, not per line. Comments stripped, whitespace collapsed, split on;. Real DDL splits one GRANT across lines; a per-line tombstone over DDL has an escape by construction. Other files match per line — keep each leg’s pattern on one physical line of the target.Append-only surfaces (migrations) lie to
present. A match proves “was built this way”, never “still is”;unique/countconflate history with HEAD. Over migrations either target the latest definition of the object, or usecount=Nas the ratchet.count=Nis a deletion ratchet, never a universal. It counts matches across ALL files the glob matches: it catches removal, not a new file that omits the pattern. Measurement 2 proved it by injection — aprivileged: truerogue container added to a default k8s manifest left fifteen anchors green at exit 0. “For every file, P” iseach; “for no file, P” iseach!— quantified per file, failing files named. Thecountdry-run nudges you here on purpose: every summary that offers a ratchet ends by namingeach/each!, so a universal-shaped rule is never pinned as a ratchet by accident.Vacuous globs fail loudly. A glob matching zero tracked files is
vacuousforabsent,unique,countandeach— and forpresentwhen its regex has nowhere to move to either (seemoved).absent/count/eachblock — a directory rename must not silently green a tombstone, and a universal over nothing proves nothing.present/uniqueare reported, and block only under--strict:vacuous INV-01 [absent] .multivac/invariants.md:7 · glob matched no tracked files — a rename greens this tombstone silently; fix the glob · blockingmovedself-heals. Apresentleg with no match inside its glob, whose matches elsewhere fall in exactly one file — of the include’s own kind (the same trailing extension), never inside.multivac/— gets its glob rewritten in place, exit 0.verify --checkand a run from a consumer repo report the move and leave the glob alone:moved INV-01 [present] .multivac/invariants.md:6 · glob rewritten to sql/migrations/001_accounts.sql — review the diffReview the diff like any other edit and let it ride the same branch. With no such file the leg stays broken — vacuous, if its glob is empty. With several files it is broken too, and the first three are named.
Choosing the mode
Anchor to contracts, not implementations — migrations, schemas, route tables, config keys, GRANTs. A contract site is simultaneously the cheapest thing to read and the most stable thing to anchor to; a widget body breaks on Tuesday. If you find yourself anchoring an implementation, first ask whether a contract site exists for the claim; anchor the implementation only when there is none, and expect churn.
| you are pinning | mode |
|---|---|
| the rule was enacted (the revoke, the constraint, the check exists) | present |
| a dead mechanism stays dead — the tombstone | absent |
| a single source of a value | unique |
| “never again” over append-only history; a sanctioned exception stays the only one | count=N |
| every matched file satisfies the rule (“every manifest declares limits”) | each |
| no matched file carries the pattern, and the offender is named per file | each! |
absent, count and each block by default; present and unique report,
and block only under --strict. Put the
teeth in the blocking modes and let present document the enactment. Without
that asymmetry every refactor turns the check red and someone disables the
tool in week three — lint-family tools die of noise, not of bugs.
The universal: each and each!
each holds iff every file the glob matches (after exclusions) contains
at least one match; each! iff every such file contains none. A violation is
a hard failure and the failing files are named, first few + count:
broken INV-90 [each] .multivac/invariants.md:9 · each: 1 of 4 files lack the pattern (api:k8s/rogue.yaml) — add it there, or exclude the file with !<glob> · blockingExempt a sanctioned file with an exclusion (!api:k8s/debug.yaml). Inside
matched .sql files the per-statement normalization applies as everywhere
else. Plain absent also says “nowhere in the glob”; reach for each! when
you want the report per file — each offender once, as “N of M files” — rather
than the first three matches. What
each cannot say — deliberately — is a cross-file relation (“the
vendored copy equals the root copy”, “the env var matches the
containerPort”). That is a different primitive, not a quantifier; leave such
claims unanchored rather than fake them.
| INV-90 | Every deployment is confined: limits declared, never privileged. | published | active | 2026-08-14 | map |
<!-- @anchor INV-90 api:k8s/*.yaml /limits:/ each -->
<!-- @anchor INV-90 api:k8s/*.yaml /privileged:[[:space:]]*true/ each! -->The legs pattern
One claim, several legs, all must hold. The canonical shape — enactment, tombstone, ratchet:
| INV-01 | Nobody has UPDATE on `accounts`, not even `admin_role`. | published | active | 2026-08-13 | map |
<!-- @anchor INV-01 api:db/migrations/*.sql /revoke[[:space:]]+update[[:space:]]+on[[:space:]]+[^[:space:]]*accounts/i -->
<!-- @anchor INV-01 api:db/migrations/*.sql /grant[[:space:]]+update[[:space:]]+on[[:space:]]+[^[:space:]]*accounts/i absent -->
<!-- @anchor INV-01 api:db/migrations/*.sql /update[[:space:]]+accounts/i count=1 -->The present proves the rule was enacted; the absent kills the re-grant;
the count=1 pins the one sanctioned update accounts in history — any
new occurrence breaks it. A tombstone can cross repos:
<!-- @anchor INV-42 *:AGENTS.md /(^|[^[:alnum:]_])legacy_token([^[:alnum:]_]|$)/ absent -->The dead-terms dictionary is not a separate feature: it is these absent
legs, accumulated on retired rows, blocking forever.
Before committing an anchor: two self-checks
Run both. An anchor that fails either is not ready.
- Misfire check — name a refactor that breaks it while the claim stays
true. File rename? The glob self-heals only for
presentwith one matching file — anabsentleg’s glob renames to vacuity and blocks. Statement rewritten in equivalent SQL? Widen the pattern to the invariant part ([^[:space:]]*accountssurvives schema-qualification; a literalpublic.accountsdoes not). - False-green check — name a violation that passes it. A grant via
GRANT ALL? Yourabsentongrant updatemisses it — add a leg. A bypass that never says the guarded word (an upsert instead of an update)? Kill it with its ownabsentleg. A directory rename? Vacuity catches the glob, but only if the glob is tight enough to go empty.
If you cannot name either, you don’t understand the claim yet — reread the contract site before anchoring it.
Not everything anchors
A meta-rule or a process rule anchors to nothing. Leave it unanchored: it is legal and counted, and the report never pretends to have verified it. Do not invent a decorative anchor to move a coverage number.