Skip to content

Citations reference

A citation pins a sentence on a page to the source lines it was written from. Both ends are pinned the same way, by a line range and a hash. This page is the vocabulary as the tool ships it. The schema it implements is the built-in manni:citations:1.2.0, which pins the claim, encrypts source.file as a value, and names the checkout a source is in. manni meta schemas lists it with the family’s other vocabularies, and manni cite checks each entry against the same file. The vocabulary’s own reference page is manni:citations:1.2.0. It also documents the strict overlay, manni:citations-strict:1.2.0.

A strategy record is the one exception. cite checks a record’s entries against manni:citations:1.3.0, which adds a claim that is a metadata value.

An entry lives in one of two places, and nothing on the page repeats it.

  • Frontmatter. A citations list, one entry per pinned range. The metadata tool’s validate sees this channel, and validates it when the schema is in the page’s set.
  • A sidecar manifest. A collection can declare an external-metadata manifest that owns the citations key, and then the page’s entries live there. See The sidecar.

The body carries no entries. It may carry a marker, which is one comment naming an entry by id. A marker is an anchor, never a record of a source.

docs/limits.md (frontmatter)
citations:
- id: fetch-timeout
claim:
lines: 3
integrity: sha256-c41f09aa8ba1a04a3d5a0a0dbb1d9c1e8e9e26a1c5f0b2dd4e9a0c17f4f19aab
source:
file: lib/limits.ts
lines: 2
integrity: sha256-78af1d3321f9cbb177a7e4c958e39be56fd14cb93c1e441778bc4232e0fe4b1f
commit-sha: 3f9c2a1e7b0d4c5a6f8e9d0b1a2c3d4e5f607182
- source:
file: lib/limits.ts
integrity: sha256-aebba92fe4cddf100cc781281d1f24ad7c234b6189413e2130d5fe71ed86e023

The second entry is a bare pin. It has no claim, no anchor and no quote. It is legal, and it is checked source-side only. It says this page rests on this file, tell me when it changes, which is source-of-truth from the stewardship vocabulary at line granularity. The two exist because they answer at different grains.

KeyTypeDescription
citationsnon-empty list of entriesOne entry per pinned range. A page with no citations omits the key; an empty list is not a declaration. Ids are unique within the page, and a duplicate is entry-invalid.

Any other key at the page root that starts with citation- is rejected by the schema. So a misspelled citation-comit fails the way a closed container would make it. The root stays open for sibling vocabularies. A page-level citation-commit fails the same way, because each entry records its own source.commit-sha.

An entry is a closed object with one required block, source. The claim block is the other end, and both carry a line range and a hash.

FieldTypeRequiredDescription
id^[a-z0-9][a-z0-9-]*$, unique per pageno; yes when a marker names the entryWhat a marker names, what a finding is reported under, and what update --only selects. Allowed on a bare pin, so an unanchored entry can still be named.
claimblocknoThe page text or, on a record, the metadata value the citation supports. Absent, the entry is a bare pin, or a marker with no drift check on its sentence.
claim.linesL or "L1-L2"yes, unless a marker names the entry or claim.pointer is setLines of the page body. See Body lines and file lines.
claim.pointera JSON Pointer, such as /pains/0no; on a record onlyOne value in the page’s own metadata, in place of lines. A claim takes one of the two, never both. See Claims on records.
claim.integritysha256- and 64 lowercase hex digitsyes, inside claimThe claimed lines hashed under the one rule. With a marker, the lines the marker anchors. With a pointer, the value’s hash. Always plain, never keyed, because the page is public.
sourceblockyesThe lines the claim rests on.
source.repo^[a-z0-9][a-z0-9-]*$noThe checkout file is in, by a name cite.repos or --repo maps to a directory. Absent, file is in the root. See sources in other checkouts.
source.fileroot-relative posix path, or ~ and 82 or more base64url charactersyesThe file. A ~ value is the path encrypted with the family key. See the file grammar.
source.linesL or "L1-L2"noFile lines, 1-based and inclusive. Absent pins the whole file. Readable even when file is encrypted.
source.integritysha256- or hmac-sha256-, and 64 lowercase hex digitsyesThe pin. hmac-sha256- exactly when file is encrypted, so the page carries no verifier for a guessed private line.
source.commit-sha7 to 64 lowercase hex digitsnoWhere the pin was minted, which is a hint rather than a record. Sixty-four covers a SHA-256 repository. check reads it first when it is an ancestor of HEAD. Otherwise it derives the source’s baseline from git, so a squash merge that drops this commit costs nothing.
quoteboolean, default falsenoThe claim is a fenced block that reproduces the source. See Quotes.
last-verifieda day, or an RFC 3339 timestamp with a timezonenoThe date by which every check in verified-by had passed. A verifying tool writes it, and manni cite never reads it.
verified-bynon-empty list of { tool, check, version, passed }noThe checks that prove the pin, such as a test that runs the cited code. See verification.

An entry is closed. A src string, a pin at the entry root, a commit key and a claim written out as text all fail the schema. The source is an object, the pin sits inside each end, and source.commit-sha records the commit.

The metadata tool’s validate checks the shape; it cannot check either pin. Whether a hash still holds is a comparison against the page and the source, and that is what manni cite check adds.

claim.lines counts the page body. The body starts on the first line after the closing frontmatter fence, and on line 1 for a page with no frontmatter. So editing the frontmatter never moves a claim.

What people see stays file lines. The command line takes cite add docs/limits.md:9, every (line N) is a file line, and every report translates. Only someone reading the YAML sees the difference.

The reason is that the entry often lives in the file it numbers. With file lines, adding a tag by hand or with meta fill would move every claim on the page. Two branches adding citations to one page would then conflict on every lines: value. Body lines never move for a frontmatter edit.

A strategy record is a page whose own metadata declares type: audience, type: persona or type: journey. On a record, a claim can be a metadata value instead of body text. claim.pointer names it, and claim.integrity pins it:

records/personas/theo.citations.yaml
records/personas/theo.md:
citations:
- id: terse-messages
claim:
pointer: /pains/0
integrity: sha256-c1ac46fcd895d6fb26f1364c6ab3930ce3662689758855f7f66ffb89e5f09e73
source:
repo: research
file: notes/2026-09-12-contributor.md
lines: 3
integrity: sha256-13745edd5eea6e243c6d3682ebd5460c0377c323ea6bda3502b500e7364b5b1b
  • What a pointer names. One value in the page’s own metadata block, as manni meta reads it. That is the frontmatter, or the format’s native header. It is an RFC 6901 pointer, with ~ written ~0 and / written ~1. Array indexes count from 0. It never names the whole block, and never anything under citations.
  • What a pointer does not reach. A value a {page}.meta.yaml manifest keeps is not in the page’s own metadata. A record’s type must be on the page too, or the page is not a record.
  • Which vocabulary reads it. cite validates a record’s entries against manni:citations:1.3.0, and every other page’s against manni:citations:1.2.0. So a pointer entry on a site page is entry-invalid, as /claim must NOT have additional properties. A record’s override names manni:citations:1.3.0 so that meta validate reads it the same way.

A pointer claim’s integrity is sha256- over the value’s canonical JSON, under RFC 8785, encoded as UTF-8. For the values a metadata block holds, that is the JSON text with every object’s keys sorted. A string is hashed with its quotes. One rule covers a string, a list and a mapping, whatever format the value was written in.

So the pin over pains[0] in the sample is the SHA-256 of "Error messages are terse with no remediation hint.", quotes included. An edit to another key never moves a pointer claim. An insert or a reorder in the same list does, and the move search follows it.

source.file is a path or a ciphertext, and nothing else. The line range is its own field, so no colon is read out of the value.

path/to/file.ts
~CIPHERTEXT

A path is relative to the root, or to the checkout source.repo names. It is posix-separated, and free of . and .. segments. Segments may hold spaces and dots, so docs/release notes/v1.2.md is a file. The grammar rejects a leading /, a drive letter, a backslash, an empty segment and a URL. It also rejects a ciphertext shorter than 82 characters, or one holding a character outside base64url. So a source that could never resolve fails at the schema rather than resolving to nothing.

The pattern, as the schema spells it:

^(?:~[A-Za-z0-9_-]{82,}|(?!~)(?:(?!\.\.?(?:/|$))[^/\\:\r\n\t]+)(?:/(?:(?!\.\.?(?:/|$))[^/\\:\r\n\t]+))*)$

Lines are 1-based and inclusive, in both blocks. That the end line is not before the start line is the tool’s rule, since a pattern cannot compare two numbers. lines: "9-3" is refused by add and is entry-invalid on a page.

On the command line and in every report, a source is still spelled as one string: lib/limits.ts, lib/limits.ts:2 or lib/limits.ts:1-3. That is a spelling of the two fields, not a third field. An entry with a repo is spelled with the name in front, as sdk:src/client.ts:2.

The one string reads [<repo>:]<path>[:<L>[-<L2>]]. A path holds no colon, and a tail of digits and hyphens is read as lines. So the parts read one way.

  • One part is a path.
  • Two parts are a path and lines when the second is digits and hyphens. Otherwise they are a repo and a path.
  • Three parts are a repo, a path and lines.

So api:2 is line 2 of a root file named api, not a file named 2 in the api checkout. A whole-file pin of a file named 2 at the top of a checkout cannot be spelled.

The root is one checkout. A page that cites code from a second repository gives the entry a repo, and the config maps that name to a directory:

docs/limits.md (frontmatter)
citations:
- id: retries
claim:
lines: 5
integrity: sha256-c315c7eabc99ccdde43c763146f93b4a3b25ae7d0b08d5f5be67c918ca446a03
source:
repo: sdk
file: src/client.ts
lines: 2
integrity: sha256-4bcf2ce6f6f0c00258d3ee02a4e5f3a104f732fb9839b38e5ebb7a95b6af5e68

file, lines and the pin read exactly as they do for the root, inside that checkout. Every read stays there, including the move search, the baseline walk and commit-sha. update never changes repo, so pointing a pin at another repository is a remove and an add. cite.repos declares the names, and an entry naming an undeclared one is source-missing.

~ followed by at least 82 base64url characters is an encrypted source. It is the path, encrypted under the family encryption key, in the same token format a manni meta value uses. source.lines sits beside it in plain text, and stays readable. Examples on these pages shorten the token to ~AQm4…, and the real value is longer. The key is a top-level encryptionKey: in manni.config.yaml, or MANNI_ENCRYPTION_KEY, which wins. manni key sets it and rotates it.

The encryption is deterministic AES-256-GCM, so one path under one key always gives one ciphertext. One thing stays apart from a metadata value, and only internally. A citation source carries its own encryption context, so a metadata tool cannot re-encrypt a source without also re-keying its pin. That would break every encrypted citation after a rotation.

source.file never carries the x-manni-encrypt mark a schema puts on an encrypted metadata value. A plain path is the ordinary case, and a mark would fail every one of them under meta validate.

A checkout that has the key decrypts the path, then checks it against the tracked files as it would a plain one. So a public docs repository can cite a private code repository without publishing its paths. The pin over an encrypted source reads hmac-sha256-, as the hashing rule says. So the page also publishes no verifier a reader could run against a guessed line.

Two things follow from the encryption being deterministic. A ciphertext is stable across pages, so a public site does reveal how often one private file is cited and when it moves. That is recorded as accepted. And update can recognise one file across pages, because its ciphertext does not change. The path is padded before encryption, so a ciphertext’s length reveals only a size class.

Without the key, check reports an encrypted citation missing, an error, unless --no-check-sources skips it. Under another key it is missing too, because the ciphertext does not decrypt.

Stated once, in the schema’s source.integrity description, and implemented once for both ends:

Decode as UTF-8. Drop one leading byte-order mark. Turn CRLF into LF. Split on LF and discard the empty element a trailing LF leaves. Take lines L1 to L2 inclusive, or every line for a whole file. Join with LF and no trailing LF. Keep trailing whitespace. A plain file is pinned sha256- over that text. An encrypted file is pinned hmac-sha256-: the HMAC-SHA256 of that text under a key derived from the encryption key.

A claim is always pinned sha256-, because the page is public and there is nothing to hide. The prefix follows source.file, and any other pairing is entry-invalid. A pointer claim’s pin is a value’s hash:

fetch-timeout: an encrypted source is pinned with hmac-sha256-, not sha256-.
fetch-timeout: a plain source is pinned with sha256-, not hmac-sha256-.

Two consequences are worth knowing. A file’s trailing newline is not a line, so lines: "1-7" and a whole-file pin over a seven-line file are one pin. And CRLF and a byte-order mark are not changes, so a Windows checkout under core.autocrlf reads every pin as current. A trailing space is a change, because it is one.

A marker is one comment in the body, naming one or more entries by id, written in the format’s own comment syntax. It is the second way to anchor a claim, and the easier one to keep true, because it travels with its text.

FormatForms
markdown, mdx<!-- cite ID ID -->, {/* cite ID ID */}, [comment]: # (cite ID ID)
html, xml<!-- cite ID ID -->
asciidoc// (cite ID ID)
rst.. (cite ID ID)

The payload is one or more ids and nothing else. cite true names the id true. The separator is a space, and only a space. A run of spaces separates as one. A comma is never a separator, so cite a,b is one token, that token is not an id, and the message quotes it back.

Every id one marker names anchors the same text. A paragraph resting on four sources therefore carries one comment rather than four. An id cannot hold a bracket, a brace or an angle bracket, so no list can close a parenthesised comment early.

A marker holds at most 25 ids. A marker is also one line, so a payload broken across two lines is marker-invalid. Six defects are checked in order, and the first one found is the finding:

The payloadThe message
is emptyinvalid marker: empty payload. Name an entry id, or remove the marker.
holds a line breakinvalid marker: a marker is one line; write two markers
holds a word opening with {invalid marker: a marker names entry ids, not an entry. Move the entry to citations:, in frontmatter or the sidecar, and name its id.
holds more than 25 idsinvalid marker: more than 25 ids in one marker (31); write a second marker
holds a word that is not an idinvalid marker: "fetch-timeout,retries" is not an id. An id is lowercase letters, digits and hyphens. Separate ids with spaces.
names one id twiceinvalid marker: "retries" is named twice in one marker. Remove one.

One marker raises one marker-invalid, so a marker with two bad words is reported once. An id with no entry is different: that is marker-orphan, one per id, and the other ids on the line still check.

A marker never carries a whole entry as JSON. An entry lives in frontmatter or the sidecar, so a source is never written into the body. A public page never carries a private path twice.

The scanner looks for the open and close delimiters over the body only, so a cite inside the frontmatter is never a marker. It also reads code as code. An opener inside a fenced block or a backtick span is text, which is how this page can show the syntax without carrying it. It reads at most 500 markers per page; past that, the rest are not read and the page carries one marker-invalid saying so.

In asciidoc, a listing ----, literal ...., passthrough ++++ or comment //// block is code, so a marker inside one is skipped. An example ====, sidebar **** or quote ____ block holds prose, so a marker inside one is read.

A marker in markdown
<!-- cite fetch-timeout retries -->
The fetch timeout is 10 seconds, and it is
not configurable.
A marker in asciidoc
// (cite fetch-timeout)
The fetch timeout is 10 seconds.
A marker in reStructuredText
.. (cite fetch-timeout)
The fetch timeout is 10 seconds.

A marker belongs above the paragraph or block it anchors, on a line of its own. cite add --marker writes it there, or appends its id to the marker already nearest the text. Markers stacked above one paragraph all anchor that paragraph, and no marker line is ever part of a pin. So joining an id into a marker changes no claim hash anywhere on the page.

A marker can also come before its entry. Write it by hand, and check reports marker-orphan until an entry has that id. cite add with that --id then adopts the marker and pins the text it anchors. A marker naming several ids takes them one run at a time.

When the nearest marker already holds 25 ids, cite add --marker writes a second marker line instead of joining. Two markers over one paragraph are legal, and each anchors it.

A marker line inside a paragraph is marker-misplaced. Deleting it would join the lines above and below into one paragraph, which is what the renderer would have shown. MDX and CommonMark both end a paragraph at a comment line, so such a page renders two paragraphs split mid-sentence:

fresh-context: the marker at line 16 splits the paragraph at lines 14-18. Its place is above line 14. Run manni cite update to move it.

Each misplaced marker is one finding, so a run of three is three. A marker that names no entry is left to its marker-orphan. cite update moves them, with no flag.

Three markers are never misplaced. One whose own line carries text after the close sits inside the line it anchors. One inside a fenced block or a backtick span is not a marker at all. And one whose line above is blank, a fence, the body’s first line, or a bound line is already where add writes markers.

A bound line is one the format makes a block on its own. No paragraph continues across it, and no pin covers it beside text.

FormatBound lines
markdown, mdxAn ATX heading, which is one to six # and then a space or the end of the line. Also a line that is nothing but one tag, such as <Aside> or </Aside>.
html, xmlA line that is nothing but one tag, such as <body>.
asciidocA section title, which is one to six = and then a space.
every formatAn adornment, which is one of -, =, ~, ^, ", ', *, +, #, _, :, < or > repeated three times or more, spaces allowed. A setext underline, a thematic break and an rst adornment all qualify.

So a marker on the line under a heading is in place, and it anchors the text under the heading rather than the heading itself.

In place under a heading
## Crawl scope
<!-- cite host-scope -->
Only the start URL's host is crawled.

An entry has exactly one anchor, and that is the line a finding reports.

AnchorHow it works
Claim linesclaim.lines names the body lines, and claim.integrity pins them. The pinned text is searched for when it is not where it was, so the claim can be claim-moved or claim-changed.
A markerThe entry has an id, a marker in the body names it, and claim.lines is absent. The marker anchors the rest of its own line when that carries text, else the paragraph after it. Every id one marker names anchors that same text, so their claim hashes are all equal. Markers stacked above one paragraph all anchor that paragraph. A line holding only a marker is never part of a pin. The marker travels with its text, so the claim reads claim-moved only when the pin holds over a span the marker no longer anchors.
NeitherA bare pin, checked source-side only. A finding on it reports the entry’s own line.

Neither anchor always wins. A marker travels with its text, but it is a line in the body, so a concurrent prose edit can conflict with it. Claim lines leave the body untouched, and a moved claim needs cite update. Choose an anchor weighs the two.

A claim under a marker is optional. With one, its integrity pins the text the marker anchors, and an edit there is claim-changed. Without one, the marker still anchors the entry. There is then no drift check on the sentence.

Claim lines and a marker together are anchor-invalid, because neither anchor can be trusted:

fetch-timeout has claim lines and a marker. Keep one.

cite add --marker writes both halves, the marker and the claim pin. A hand-written marker over an entry with no claim is legal.

A quote entry and a plain entry cannot share a marker. A quote anchors the next fenced block and a plain entry anchors the paragraph, so one line would mean two spans. That mix is anchor-invalid:

quoted-limits: a quote entry shares a marker with a non-quote entry. Give the quote its own marker.

A marker whose ids are all quotes is legal, and so is one whose ids are all plain. Two markers stacked above one block are legal too, one for each kind, and cite add --marker writes the second one instead of joining the first.

quote: true says the claim is a fenced block that reproduces the source. The pin covers the whole block, fences included, and the lines inside the fences are compared with the source. So a quoted snippet that drifts from its source is quote-drift even while the pin still holds.

A quote needs an anchor. Under claim lines, those lines must still be a fenced block. Under a marker, the block is the next fence after the marker. Neither is anchor-invalid:

fetch-timeout: quote needs a claim or a marker. Add claim.lines over the block, or a marker above it.
fetch-timeout: the quote's claim lines 14-18 are no longer a fenced block. Restore the fences, or re-add the citation over the block.

The fence is ``` or ~~~ in markdown and MDX, at any indent. It closes on the same character, at least as many, and nothing else on the line. In asciidoc the fence is any delimited block: ----, ...., ++++, ////, ====, **** or ____. It closes only on an exact copy of its opening line. html, xml and rst have no fence the tool can locate.

A collection can keep its pages’ citations in an external-metadata manifest instead of in each page’s frontmatter. There is no new config key. The mechanism is the one the metadata tool already has, and the manifest owns the citations key:

manni.config.yaml
collections:
- name: site
paths: ["docs/**/*.{md,mdx}"]
externalMetadata:
- file: docs-citations.yaml # relative to the config file
keys: [citations]

The manifest is keyed by page path, or by a page field under join::

docs-citations.yaml
docs/limits.md:
citations:
- id: fetch-timeout
claim: { lines: 3, integrity: sha256-c41f09aa… }
source: { file: lib/limits.ts, lines: 2, integrity: sha256-78af1d33…, commit-sha: 3f9c2a1e… }

Reading. check, update, add and manni key rotate read a page’s citations from the manifest that owns them, through the metadata tool’s merge. Which manifest owns a page is decided by every collection in the config, whatever --collection or the positional paths select. So a page checked by path still finds its sidecar. --no-config reads frontmatter only.

Writing. add, update and manni key rotate edit the manifest in place. They splice only that page’s citations value, so every other byte of the file is unchanged, and they read the file back to confirm it. update writes each manifest once per run. These are the first writers of a manifest. meta fill and meta query stay read-only.

Where findings sit. A claim finding or a marker finding sits on the page line, where a reviewer reads it. A finding about the entry itself sits on the manifest and the entry’s own line, which is entry-invalid or a bare pin whose source changed. SARIF drops a location outside the repository, so a manifest outside it is reported on the page instead.

Refusals follow the metadata tool’s rules, and each is exit 2:

RefusalWhy
A URL manifest that owns citations.cite writes citations, and a URL manifest cannot be written. It would also put private paths in public CI output.
A page in two collections whose manifests both own citations.Two owners for one key, with nothing to say which writes.
Two pages sharing one join: value, where the manifest keeps their citations.The metadata tool’s duplicate rule.
A manifest entry naming a page that is gone.The metadata tool’s orphan rule, on a whole-collection run. A git mv is the usual cause.

A page that still carries its own citations: while a manifest owns the key is external:owned in meta validate, and entry-invalid in cite check.

manni: manni.config.yaml: collection site: citations cannot come from a URL manifest, because cite writes them.
manni: docs/limits.md is in collections site and api, and both keep citations in a manifest.
manni: A page read from stdin has no path, and its citations live in docs-citations.yaml, which is keyed by path.

The sidecar is what makes the two-repository layout comfortable. The manifest sits in the docs repository with source.file encrypted, and no page carries a line of citation YAML. See Public docs, private code.

check classifies each end of a citation, then applies the severity table. A status is a finding when its rule’s severity is not off; the anchoring rules are findings in their own right.

A severity is one of the family’s three levels, or off. The defaults are in the tables below, and severity: in config moves any of them.

SeverityReportedFails the rungithubSARIF levelJUnit
erroryesyes, exit 1, unless baselined::errorerrora failure
warningyesno::warningwarninga passing testcase
noticeyesno::noticenotea passing testcase
offnononothingnothingnothing

current and skipped are statuses, per end, and neither is a finding. current says the end hashes to its pin. skipped says the end was not read, which happens to every source end under --no-check-sources. Neither can be named in severity:, which takes the seventeen rule names below.

Every message ends with what to do. In pretty output that sentence prints under the row, then the rule line and a Guide link to the fix page.

RuleMeaningMessageDefault
source-movedThe pin does not hold at its range, and exactly one window of the same length hashes to it. That window sits elsewhere in the file, or in another file a commit touched. update rewrites source.lines, and source.file too where the pin changed file. A whole-file citation never moves inside its file, and does follow a rename.moved -> lib/config/limits.ts:4. Run manni cite update.warning
source-moved-ambiguousTwo or more windows hash to the pin, in one file or across several; the tool will not guess which. Widen the range.moved, 2 candidates (lib/config/limits.ts:4, vendor/limits.ts:4). Widen source.lines until the range occurs once, then run manni cite update --accept --only fetch-timeout.error
source-changedNo window hashes to the pin. With a baseline: since when, and how many commits touched the path. The message names the span the old first and last line now cover, when each sits exactly once and in order. Where the history ends before the pin held: history unavailable, plus one run notice about fetch-depth: 0. Where git is not available, the recorded commit or none, plus one run notice.changed since 3f9c2a1, 2 commits; now at lines 200-215. Check the sentence against the source, then run manni cite update --accept --only fetch-timeout.error
source-never-trueNo window of the range’s length anywhere in the file hashed to the pin at any commit in the source’s history. Asserted only when the walk reached the file’s first commit, in a clone that is not shallow. No span is offered, because the pinned lines never had a home.never true: the pin held at no commit in the history of lib/limits.ts. Check the sentence against the source, then run manni cite update --accept --only fetch-timeout.error
source-missingThe path is not a tracked file under its checkout (without git, not a file under it). Or the entry names an undeclared repo, or an encrypted source cannot be decrypted. It is reached only once no file a commit touched holds the pin.missing. Run manni cite remove --only fetch-timeout, then manni cite add against the file's new path. For an encrypted source the status is one of missing (no encryption key is available to decrypt it), missing (does not decrypt under the current key) or missing (no tracked file matches; wrong --root?), then what to do about that cause. An undeclared repo reads missing (no repo named sdk is declared). An encrypted source in a repo that matches no tracked file reads missing (no tracked file matches in sdk; wrong cite.repos.sdk?).error

The move search runs whenever the pin does not hold, and history decides only what a non-match is called. With the original text (git could show the commit), candidates are the lines where the first line matches. Each candidate is compared lexically and hashed on a full match. Without it, a window of 2,000 lines either side of the pinned range comes first. Then the rest of the file follows, under a 64 MiB hashing budget. Past the budget the entry is source-changed with truncatedSearch set, and the run says so once. A range longer than 5,000 lines, at either end, is entry-invalid.

A pin that holds nowhere in its own file is looked for in the files that changed since its baseline. The candidate list is git diff --name-only <baseline> HEAD, so it covers committed changes only, and every path the tracked-file index does not hold is dropped. One hit is source-moved with the new path in it, and several are source-moved-ambiguous. The 64 MiB budget is one counter per citation, spent by every search that settles it. Both searches need the original text, so a pin with no baseline is classified as it always was.

A source finding says since when, and the commit it names is the source’s baseline. The recorded commit-sha is where check looks first. It is taken, with one git show, when it is an ancestor of HEAD and holds the pin. Otherwise check walks the commits that touched the source, newest first, and takes the newest one that held the pin. A commit where the path is absent is walked past, so a rename does not end the walk. The walk reads at most 256 commits.

So a squash, rebase or cherry-pick merge that drops the recorded commit costs nothing. The baseline is the commit main took instead. A walk that ends before the pin held, in a shallow clone or at the cap, reads history unavailable and names fetch-depth: 0. JSON output carries both. commitSha is the recorded value and baselineSha the derived one.

RuleMeaningMessageDefault
claim-movedThe pinned page text is found verbatim at other lines, so nothing drifted. update rewrites claim.lines. For a marker, the pinned text is intact but the marker now anchors a different span, and update re-pins it there. On a record, a pointer claim’s value sits verbatim at one other pointer, and update rewrites claim.pointer.fetch-timeout: the claim moved from line 9 to line 11. Run manni cite update.notice
claim-moved-ambiguousThe pinned text is found verbatim at several places. update skips it. For a pointer claim, the value sits at several pointers, and the message names each.fetch-timeout: the claim at line 9 now appears at lines 11 and 30. Pin a range that occurs once, or anchor the claim with a marker.warning
claim-reanchoredThe words the pin covered are unchanged since the claim’s baseline, and only the layout moved. Three changes reach it. A rewrapped paragraph, a hoisted marker, and a marker line the anchor rule stopped counting. A plain update re-pins it, with no --accept.retries: the claim at lines 15-17 was reanchored since 3f9c2a1. Its words are unchanged. Run manni cite update.notice
claim-changedThe words the pin covered were edited. The message says since when, where the claim has a baseline. update --accept re-pins it, and a blanket accept refuses a line that shares no sentence with the claim at its baseline. Naming the id with --only accepts that line too. For a pointer claim, the value at the pointer was edited and the pinned value is nowhere else in the record.fetch-timeout: the claim at line 9 has changed since 3f9c2a1, 2 commits. Check it still matches the source, then run manni cite update --accept --only fetch-timeout.warning
claim-missingA pointer claim’s value is gone. The pointer names no value, or names the value another entry’s pointer claim pins, which is a deleted item whose neighbour shifted up. A body-line claim never reads it. update skips it.unknown-schema: the value pinned at #/pains/0 is gone. Run manni cite remove --only unknown-schema. If the claim still stands, cite add it at its pointer now.warning

claim-changed defaults to a warning, not an error. It fires on an edit to the words the pin covered, and a typo fix counts. Under claim lines that is an edit on those lines. Under a marker it is an edit anywhere in the paragraph or block the marker anchors. A red build for that would block prose work for nothing. A team that wants every edit to a cited sentence reviewed sets it to error.

claim-reanchored is a notice, for the reason claim-moved is one. The words are found unchanged, so nothing on the page stopped being true, and update repairs it without being told. A rewrap or a hoisted marker therefore never fails a run at the default severities. At off the finding is dropped, and update re-pins the claim all the same.

There is no claim-ambiguous. A pinned range cannot be ambiguous on the page it was taken from. That is the whole gain of pinning the claim rather than copying it.

A claim finding says since when, and the commit it names is the claim’s baseline. Nothing stores it. check walks the commits that touched the page, newest first, and takes the newest one whose page still held the pin. The walk reads at most 256 of them. It reads the sidecar manifest beside the page at each commit, so an entry in a manifest has a baseline too.

Where HEAD holds the pin and the working tree does not, the change is uncommitted, and the message says so rather than counting zero commits. Where no commit held the pin, the claim reads claim-changed with no baseline. A checkout whose history ends first reads history unavailable and names fetch-depth: 0. Where git is not available, the run says once that citations are checked without history. No claim reads claim-reanchored without a baseline, because the words test needs the text the pin covered then.

The words test is what tells a layout change from an edit. It normalizes three strings the same way. Every line holding only a marker is dropped, and every run of whitespace, line breaks included, becomes one space. Punctuation, emphasis and inline markup count as words.

  • The pinned words. The words the pin covered at the baseline.
  • The claim now. The words the anchor covers now.
  • The page then. The whole page body at the baseline.

The claim is claim-reanchored when the pinned words sit inside the claim now, and the claim now sits inside the page then. Both comparisons run at word boundaries. Otherwise the claim is claim-changed. The first condition catches a word edited inside the claim. The second catches a sentence added beside it, or a marker now anchoring a different paragraph.

A claim that stores its own lines moves onto the run its words sit in, and the finding names that run:

page-size: the claim at line 21 was reanchored since 5d1e0b7, to lines 21-23. Its words are unchanged.

Where the claim’s words sit at several runs of lines, the finding is claim-moved-ambiguous instead, with its own message. A quote: true claim is never reanchored by collapsed whitespace. Its test is equality on the lines the hashing rule reads, with marker lines dropped.

A blanket update --accept re-pins a changed claim over whatever its line holds now. One guard stands between it and a line that was replaced rather than edited.

The guard refuses a claim whose line shares no sentence with the claim at its baseline. It refuses one holding under half the claim’s words too, which is the drift onto a neighbouring table row. It needs a baseline to judge at all. A pin update minted has none until a commit holds it, so an accept run straight after an update is unguarded. --dry-run shows every claim a run would re-pin, with its text, before anything is written.

Naming an id with --only bypasses the guard, because naming it is the judgement the guard asks for. The report says replaced there rather than changed. The CLI reference has both report lines.

Three refusals need no baseline. --accept refuses a cite marker, a span of table rules, and text the page repeats. The CLI reference carries each message and the bypass.

RuleMeaningDefault
marker-orphanA marker names an id no entry has.error
marker-invalidA malformed marker. An empty payload, a line break, a brace, more than 25 ids, a word that is not an id, or a repeated id. Also more than 500 markers on a page.error
marker-repeatedTwo markers name one id; the first anchors it.warning
marker-misplacedA marker line sits inside a paragraph, between two lines of text. It splits the paragraph for the renderer and for the tool. update moves it to where cite add --marker writes markers.warning
anchor-invalidThe anchor cannot work. Four cases reach it. Claim lines and a marker together, or quote with no claim and no marker. A quote whose claim lines are no longer fenced. A quote entry sharing a marker with a plain one.error
entry-invalidA schema failure, a range that ends before it starts or runs longer than 5,000 lines, or a duplicate id. Also a pin prefix that does not match file, a reserved citation-* page key, and a page citations: a manifest owns.error
quote-driftThe quoted block no longer reproduces the source, or no fenced block follows the marker.error

A fourth anchor-invalid case cannot arise. Claim lines inside the frontmatter are unreachable, because body-relative lines never point there. That case survives only as an add refusal.

Every rule reports under the id manni:cite/<rule>, which is the SARIF rule id and the JUnit failure type. A baseline fingerprint is built from that schema, the rule, the entry’s position and a subject. The position is /citations/N. The subject is the id, or the source.integrity when there is none, never the claim’s pin. So accepting a changed claim does not reopen a baselined finding. The subject survives a move and a line shift, and changes only when the source pin is re-minted. A key rotation is the exception. Over an encrypted source the source.integrity is the keyed pin, which manni key rotate changes. Give such a citation an id, or re-record the baseline after a rotation.

Output never says more than the page did. A finding, in every format, spells a source exactly as the entry spelled it: ~AQm4…:4, never the path it decrypts to. The decrypted path, the diff since the commit and the commit subjects live on the result and reach output through the pretty reporter only, under --reveal and --show-diff. json, github, sarif and junit never carry them, so a public CI job can upload a SARIF log from a run that decrypted private sources. A sentinel test renders json, github and pretty from a fixture whose key and private path are known strings, and asserts neither appears. A second asserts the same of the findings sarif and junit are rendered from.

TermMeaning
citationOne entry, with a source end, optionally a claim end, and optionally an id and a quote flag. Lives in a page’s citations list or in a sidecar manifest.
claimThe page text a citation supports, pinned by body lines and a hash. On a record, a metadata value pinned by a pointer and a hash. Not a copy of the sentence.
sourceThe file, the lines, the pin and the commit the claim rests on.
pinAn integrity value: the hash of an end’s lines under the rule, at the moment it was minted. A record of the past, not a copy of the present, which is why it is stored rather than recomputed.
markerA cite <id> comment in the body, in the format’s own syntax. It names an entry and anchors it. It never holds a source.
sidecarAn external-metadata manifest that owns the citations key for a collection’s pages.
encrypted sourceA ~ and at least 82 base64url characters in source.file: the path, encrypted under the encryption key. Readable only where both the key and the files are.
encryption keyThe family secret that encrypts private source paths and keys the pins over them. From MANNI_ENCRYPTION_KEY, else a top-level encryptionKey: in config. A salt: under cite: is refused. manni key sets and rotates it.
driftThe gap between what a page says and what its source now holds. A source-changed citation is drift at the sentence; quote-drift is a reproduced block that no longer matches.