Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

Amiss checks that your documentation and your code still agree. It reads the documents in a repository, finds every link and path they mention, and follows each one into the same repository. When a link points at a file that is gone, it reports that. When the file is still there but its content changed while the paragraph describing it did not, it reports that too. It never reads meaning: it cannot tell you whether a sentence is true, and it does not try.

A run compares two exact states of the repository: the base (usually where your branch started) and the candidate (usually the commit being reviewed). Amiss answers four questions about them, and nothing else:

  1. Does every link or path in a document still point at something in the candidate tree?
  2. Did the content or file mode of a referenced file change between base and candidate?
  3. Did the paragraph holding the reference change too, stay exactly the same, disappear, or become impossible to match up without guessing?
  4. What did the scan actually see: which documents it read, skipped, could not parse, or found unreachable?

The fourth question matters as much as the first three. A checker that silently skips what it cannot handle is worse than no checker, because its green result claims more than it checked. So everything Amiss cannot read or follow becomes a visible row in the report, and a document it cannot decode at all fails the run rather than dropping out of it.

Amiss keeps no state. There is no baseline file, no cache, no database, and nothing committed to your repository. Run it twice on the same commits and you get byte-identical reports. It also accepts no instructions from the repository it scans that would weaken the check, no ignore comments, no severity overrides, because a check the checked code can switch off is not a check. How the project arrived at that stance is told in Provenance.

Each promise below is enforced by a test in the suite:

  • It never writes to your repository. The tests snapshot the whole tree before and after every command and compare, and they also run it against a tree it has no permission to write.
  • It never runs your code and never calls the git command. It reads Git’s files directly: objects, packs, and the index.
  • It never follows symlinks while reading. A link placed at the repository root, at .git, or anywhere along an object’s path is refused, and the refusal is never confused with a missing file.
  • It never touches the network. The engine’s dependencies contain no networking library.
  • The same repository, commits, and engine binary give the same report bytes, run after run.
  • Every internal limit is a published number. Hitting one produces a typed error naming the limit and the observed value, never a hang or a silent cutoff.

The rest of this book walks those promises in the order a run does: what counts as input, what gets scanned, how references resolve, what the report says, and where the boundaries sit. Start with Invocation if you just want to run it.

Invocation

Install from crates.io, or build from source:

cargo install amiss

The command line is closed: the grammar below is everything, each option appears at most once, order does not matter, and anything else is rejected as an invalid invocation. There is no --help, which is why the grammar is written out here.

amiss check --repo <path> --object-format <sha1|sha256>
            --base <full-oid> (--candidate <full-oid> | --index)
            [--repository <host>/<owner>/<name>
             --ref refs/heads/<name>
             --default-branch-ref refs/heads/<name>
             [--forge <github|gitlab|gitea>]]
            --profile <observe|enforce>
            [--explain-scope] [--format <human|json>]

--base and --candidate take full commit IDs, never branch names or short forms. Amiss evaluates exactly the trees you name and resolves nothing for you. Use --index instead of --candidate to check what is currently staged against a base commit. An entry marked skip-worktree is still part of the staged state and is read from the index like everything else.

The optional --repository triple tells Amiss which repository this tree belongs to, which turns links like https://github.com/<owner>/<name>/blob/main/src/lib.rs in your prose into paths it can actually check. Without the triple, such links are treated as foreign URLs and skipped, and the report says so. The host is any spelling without a slash: Amiss never resolves it, and it is matched byte for byte against the URLs in your documents, so pass the lowercase form your links actually use. The owner is one or more slash-joined segments, nested only for GitLab group paths, and owner and name must be lowercase. Forges report them with whatever capitals the owner registered, so a workflow passing github.repository has to lowercase the value first. Amiss will not do that for you: the identity you pass is a claim it cannot verify, and a checker that silently rewrites an unverifiable claim has started making things up. It refuses instead, and the refusal says why.

--forge names the URL dialect the resolver applies: github for GitHub and GitHub Enterprise, gitlab for GitLab’s separator form, gitea for Gitea, Forgejo, and Codeberg. Without the flag, github.com, gitlab.com, and codeberg.org select their own dialects and any other host selects none, in which case absolute links to that host stay foreign and the report’s evaluation.forge is null. An explicit flag always beats the table, which is how a self-hosted instance gets its grammar. The github and gitea dialects refuse a nested owner they could never match.

--explain-scope prints the scanning scope rules and exits. --format json emits the exact report described in The report; human prints the same facts readably, capped at the first two hundred findings.

Exit codes are three classes, not detail. Exit 0: the run completed and nothing blocks. Exit 1: the run completed and at least one finding blocks. Exit 2: something prevented a trustworthy result, an unreadable repository, a bad invocation, a crossed limit, an undecodable document. Details live in the report; the exit code only tells you which of the three worlds you are in.

Profiles and findings

A finding is one fact the scan established: one link, one file, one document, with four parts. The kind says what happened. The attribution says whose change it is: introduced by this candidate, pre-existing before it, resolved by it, not-applicable when the before-and-after framing does not apply, or unknown when the match-up could not be decided without guessing. The disposition says what the run does about it: record (noted), warn (shown), or fail (blocks). The location says where, down to byte offsets.

The profile picks the default disposition for each kind. observe warns on everything and blocks on nothing; that is where a rollout starts, because it makes existing breakage visible without turning the repository red on day one. enforce makes a broken reference fail the run, and is meant to become a required check once the old breakage is cleaned up.

Two rules hold in both profiles. A link that does not resolve is always the serious case: explicit-target-missing is what blocks under enforce. And a file changing under an unchanged paragraph never blocks in any profile, because the change might be exactly what the paragraph already says. Amiss reports that the code moved and the prose did not. Whether that is a problem is a human call, and the tool refuses to fake it.

The kinds, with what each one means:

kindmeaning
explicit-target-missinga link or path points at nothing in the tree
explicit-target-type-mismatchit points at a file where a directory was written, or the reverse
invalid-referencethe reference cannot be resolved as written, for example a path escaping the repository
subject-changedthe paragraph holding the reference changed
dependency-changed-subject-unchangedthe referenced file changed; the paragraph did not
dependency-and-subject-cochangedboth changed together, the healthy case
explicit-reference-removeda reference that existed in the base is gone
document-removeda whole document left the tree
unlinked-documenta document nothing links to and that links to nothing
external-out-of-scopea URL to somewhere Amiss does not check, counted and left alone
opaque-html-regionraw HTML the parser cannot see into, size and place reported
opaque-mdx-regionthe same for MDX expressions and JSX
unsupported-reference-semanticsanchors, site routes, symbols: real checks that belong to other tools
unsupported-document-formata document class Amiss knows it cannot parse
unsupported-target-kindthe target is a symlink or a submodule, which Amiss will not follow
unsupported-version-scopea reference pinned to a branch or version the scan cannot vouch for
unsupported-capabilitya policy asks for something this version does not do
observation-correlation-ambiguoustwo matches were equally plausible, so no guess was made
policy-weakenedthe candidate loosens its own policy file
coverage-reducedthe candidate shrinks what gets scanned
control-plane-changedthe candidate touches the control configuration
debt-worsened, debt-expired, waiver-invalidthe external-control cases, see Controls and policy

The last four families exist so that loosening the rules is itself reported under the rules being loosened. A repository policy may raise any kind’s disposition and may never lower one. There is no suppression syntax anywhere; the way to silence a finding is to fix what it points at.

Running it in CI

The short form is the published action, which carries the engine inside the pinned tree, derives both commits from the triggering event, and turns findings into file annotations on the pull request:

- uses: actions/checkout@<pinned-sha>
  with:
    fetch-depth: 2
- uses: HardMax71/amiss@v0

The moving major ref follows the engine’s semver major, v0 for the 0.x series and v1 from 1.0.0 on, so one series can never rewrite another’s ref; action/vX.Y.Z tags are immutable exact pins, and pinning the full commit id works as everywhere. The action verifies the selected binary against the release manifest shipped in the same tree before running it, fails the job on exit classes 1 and 2 under the default enforce profile, and exposes exit-class and report outputs for anything downstream. Its inputs (profile, base, candidate, repo, object-format, annotations) exist for the cases the defaults do not cover. The identity host comes from the event’s server URL, so on GitHub Enterprise Server the report claims the instance’s own host and recognizes that host’s blob and tree links, with the github dialect declared explicitly; nothing about the action assumes github.com.

The long form is the engine invoked directly, which is how Amiss runs on its own repository under --profile enforce, and the job is four commands. This shape is lifted from this repository’s workflow:

- uses: actions/checkout@<pinned-sha>
  with:
    fetch-depth: 2
    persist-credentials: false
- run: cargo install amiss
- env:
    REPOSITORY: ${{ github.repository }}
    BRANCH: ${{ github.head_ref || github.ref_name }}
    DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
  run: |
    amiss check --repo . --object-format sha1 \
      --base "$(git rev-parse HEAD~1)" \
      --candidate "$(git rev-parse HEAD)" \
      --repository "github.com/${REPOSITORY,,}" \
      --ref "refs/heads/${BRANCH}" \
      --default-branch-ref "refs/heads/${DEFAULT_BRANCH}" \
      --profile enforce --format json > amiss-report.json

Three details carry weight. fetch-depth: 2 exists because a scan needs a base commit, and on a pull request checkout the parent of HEAD is the pull request’s base. The repository and branch names go through environment variables instead of being pasted into the script by the workflow engine, because a branch can be named anything, and text pasted into a shell script becomes code. And the owner is lowercased in shell because GitHub hands it over with its original capitals while Amiss requires the lowercase form and refuses anything else.

On a pull request, compare the merge result against its first parent, as above. On a push, the same two commits work. A scan is a pure function of the two trees, so there is no cache to warm and nothing to restore between runs.

When a run blocks, read the JSON, not the human printout. The printout stops at two hundred findings, so a repository with hundreds of harmless records can fill the screen and still not show the row that blocked. The blocking rows are in the report’s errors array and in the findings whose effective_disposition is fail.

Snapshots

A run reads exactly two states of the repository: a base and a candidate. Each is named by a full commit ID, or the candidate can be the staged index. Nothing else counts as input. There is no working-directory mode, no branch-name resolution, and no fetching. If a needed object is not in the local object store, the run refuses; see Limits and refusals.

Amiss reads Git’s storage itself instead of asking the git command. Loose objects, packfiles, deltas, and the index file are parsed by the engine, and the parsers reject instead of repairing. A tree with entries out of order, an index whose checksum does not match, a delta chain deeper than the published limit: each one is a typed refusal, never a best-effort read. Every SHA-1 object is re-hashed as it is read, with collision detection switched on, so an object that does not hash to its own name simply does not exist as far as the evaluation is concerned.

File access happens through directory handles opened step by step, never following links. A symlink, junction, or reparse point at the repository root, at .git, at objects, or anywhere along the path to an object is refused outright. The refusal is a different error from the object being absent, and that difference is deliberate: someone who can plant a link must not be able to make the scanner read files outside the repository, and must equally not be able to disguise the attempt as a missing object.

What a refusal looks like in the report’s errors array, here for a base commit the store does not hold:

{
  "code": "GIT_OBJECT_MISSING",
  "phase": "git"
}

The row also carries path, path_bytes_hex, resource, configured_limit, and observed_lower_bound fields, null wherever they do not apply, so every refusal has the same shape and a consumer never parses two formats. When the refused thing is a name the path grammar rejects, path_bytes_hex holds its exact bytes as lowercase hex, so the report never swallows what it refused.

Neither snapshot is trusted more than the other. A base commit missing from a shallow clone is a refusal, not an empty tree, because treating an absent base as empty would make every document look newly added and flood the report with false introduced findings. Comparing two trees only means something when both trees are exactly the ones you asked for.

Discovery

Discovery decides which files count as documents, and it is deliberately narrow. Markdown and MDX files are documents, classified structured-markdown and structured-mdx. A small fixed list of well-known extensionless names, llms.txt among them, is classified plain-advisory and scanned by a profile that extracts nothing but honestly reports that the file exists and was not parsed. Every other file is a possible link target, not a document.

Seven directory names are always skipped, wherever they appear in a path:

node_modules  vendor  third_party  dist  build  .next  target

The list is fixed and cannot be narrowed by configuration. Skipping is visible in both directions: skipped documents still show up in the report’s counts, as excluded. This repository relies on the rule itself: its vendored parser test corpus lives under corpus/third_party/ exactly so that fixture files full of deliberately broken links are never read as prose.

Five paths through the classifier:

docs/guide.md               structured-markdown   scanned
site/page.mdx               structured-mdx        scanned
llms.txt                    plain-advisory        scanned, nothing extracted
vendor/lib/README.md        excluded              the vendor component is in the closed set
src/parser.rs               not a document        a reference target only

Every count is reported: discovered, scanned, unsupported, excluded, unlinked. A document that nothing links to and that links to nothing is also reported as an unlinked-document finding, because a page nobody can reach is worth knowing about even though it blocks nothing.

Paths are treated as bytes. Amiss does not fold case and does not normalize Unicode, because Git addresses files by exact bytes, and a checker that guesses two names are equivalent will eventually insist that two different files are the same file. A name whose bytes are not valid UTF-8 is still a name: the entry is classified by the same suffix rules, scanned, and reported, with its path written as a bytes_hex object naming the raw bytes as lowercase hex, since JSON text cannot carry them directly. Only a name outside the path grammar itself, one containing a backslash or a NUL byte, or a bare . or .. segment, is refused. That refusal is never quiet: the run stops as incomplete, the error is recorded as UNREPRESENTABLE_PATH with the exact bytes in path_bytes_hex, and the exit is 2. Dropping such an entry silently would be the worst bug this tool could have: the report would come back green with a document missing from it, and a missing row is the one defect no reader can notice.

Resolution

Parsing turns each document into a list of occurrences: inline links and images, reference style links, and autolinks. Each occurrence keeps two spellings of its destination. The raw one is the exact bytes from the source. The semantic one is what those bytes mean after the format’s own decoding. So [a](&amp;b) records both &amp;b and &b, and a change to either the spelling or the meaning is visible later.

What the parser cannot see into is declared instead of skipped. Raw HTML blocks and MDX expressions become opaque regions, reported with their size and place as opaque-html-region and opaque-mdx-region findings, so a link hidden inside JSX is a stated blind spot rather than an invisible one.

Each destination then resolves against the tree, and only three shapes are in scope. A relative path resolves from the document’s own directory and must stay inside the repository; ../../etc/passwd is an invalid-reference, not a file read. A repository-rooted path resolves from the root. And when the invocation provides the --repository triple and a dialect, a URL on the declared host that names this same repository in the dialect’s own spelling and a ref the scan can vouch for is converted to the path it points at. Every other URL is external-out-of-scope: counted, reported, left alone.

Three dialects exist, each pinned to the exact URL grammar its forge’s browser emits. The github dialect reads owner/name/blob-or-tree/ref/path and serves GitHub and any GitHub Enterprise host the identity declares. The gitlab dialect reads the canonical separator form group[/subgroup...]/name/-/blob-or-tree/ref/path, nested groups compared whole. The gitea dialect serves Gitea, Forgejo, and Codeberg with typed selectors: src/branch/ splits like the others, src/commit/ resolves exactly when its full lowercase object id is the candidate commit and is out of version scope otherwise, and src/tag/ is always out of version scope because no tag is a trusted ref. Line anchors follow the forge: #L10-L20 is a line reference on github and gitea, #L10-20 on gitlab, and each spelling is nothing on the other’s forge. A recognized reference’s intent kind names the dialect that read it, not the host, so an Enterprise repository’s links carry the same kind GitHub’s do.

One document, every destination shape:

[guide](guide.md)                     resolves beside this document
[root](/docs/guide.md)                resolves from the repository root
[escape](../../etc/passwd)            invalid-reference: it leaves the repository
[dir](sub/)                           the author promised a directory
[gh](https://github.com/o/r/blob/main/src/lib.rs)   a path, when the triple names o/r on github.com
[web](https://example.com/manual)     external-out-of-scope: counted, left alone
[anchor](guide.md#setup)              the path resolves; the fragment is recorded, not checked

The same decision, drawn:

resolvedestdestinationrelrelative orrooted pathdest->relghforge URL,same repositorydest->ghwith the tripleotherany other URLdest->othertreeresolve againstthe treerel->treegh->treeextexternal-out-of-scopeother->exthittarget bytesand mode readtree->hitfoundmissexplicit-target-missingtree->missabsent

Resolution is exact, and the small rules matter. A trailing slash means the author promised a directory, so sub/ must be a tree and guide.md/ is a type mismatch even though guide.md exists. Percent-encoding is decoded exactly once, so %252F stays as the literal three characters %2F in the name instead of turning into a second slash. A percent escape may decode to bytes that are not text at all, and those bytes are simply the path: bad-%FF-name.md resolves against the tree entry carrying that exact byte, because Git names files in bytes and so does the resolver. Query strings and fragments are recorded as digests but ignored for resolution, because a tree has no queries and no anchors. One narrow divergence is deliberate: a fragment whose escapes decode outside UTF-8 is dropped rather than digested, since carrying it would change the recorded identity of every existing observation for no resolution gain. Heading anchors, site routes, code symbols, and version-pinned references are all reported as unsupported-reference-semantics: real checks for those belong to tools that have the right information, and a guess here would turn honest ignorance into a false pass.

Each resolved target is read from the object store and hashed, so the comparison knows the exact bytes and file mode on both sides. A symlink or submodule target is unsupported-target-kind, because following one leaves the world of exact bytes where the guarantees live. A Git LFS pointer file is recognized and its declared content digest is carried, so swapping the large file behind a pointer counts as a change even though the pointer text barely moves.

Correlation and impact

The base-versus-candidate comparison works per occurrence, and the unit it reasons about is the block: the paragraph, list item, or table cell that contains the reference. An occurrence in the candidate is matched to its base counterpart when it is the same reference, spelled the same way, in the same structural place. That matching is called correlation.

Correlation never guesses. If a page was rewritten so heavily that two identical references could each descend from either of two originals, the result is an observation-correlation-ambiguous finding with attribution unknown, not a coin flip. Repeats are merged: one finding per distinct fact, carrying a count, so a link repeated in five places is one row with a multiplicity of five rather than five rows.

For each matched pair, the two snapshots tell one of three stories:

  • subject-changed: the block holding the reference changed.
  • dependency-changed-subject-unchanged: the referenced file changed and the block did not. This is the finding the tool exists for, and it never blocks: the code moved and the prose did not, which is a reason for a person to look, not a machine’s verdict that the prose is now wrong.
  • dependency-and-subject-cochanged: both moved together, which is what a maintained page looks like, recorded at the lowest level.

The two-sided comparison reduces to a quadrant:

dependency unchangeddependency changed
block unchangedno findingdependency-changed-subject-unchanged
block changedsubject-changeddependency-and-subject-cochanged

And the finding the tool exists for, as a change:

 fn parse(input: &[u8]) -> Ast {
-    tokenize(input).fold(Ast::new(), Ast::push)
+    lex(input).try_fold(Ast::new(), Ast::push).unwrap_or_default()
 }
The parser tokenizes the input and folds the tokens into the tree.

The code block moved and the paragraph did not: dependency-changed-subject-unchanged, a warning in every profile, pointing a reviewer at the paragraph with the line and column of the reference that ties them together.

Removals get their own kinds. explicit-reference-removed means a reference that existed in the base is gone from the candidate. document-removed means the whole file left the tree. Both are records, not accusations, because deleting stale prose is usually the fix, and the report treats it as information about the change.

Formatting noise stays out by construction. Amiss does not normalize the content of referenced files; any byte change in a target is a change, even a formatter’s, because every normalizer is a parser for someone else’s language and each one shipped would be a place for a real change to hide. For the block itself, the compared projection is structural, so re-wrapping a paragraph without changing its text does not create fake impact.

The report

--format json writes exactly one line to stdout: the canonical JSON of the report, then a newline. Canonical means RFC 8785 canonical JSON: keys sorted, one byte sequence per possible document, so the same input through the same engine binary always produces the same bytes. The payload facts agree across platforms; the envelope’s own digests differ by build, because they name the exact binary that ran. Duplicate keys are rejected everywhere on input, and the contract’s numbers are integers, never floats.

The outer envelope carries the payload plus three self-descriptions: payload_digest, a hash of the payload’s canonical bytes; engine_digest, a hash of the binary that produced the report; and compatibility, which says experimental for the v0 series. Every digest in the system is domain-separated, meaning the hash input starts with a label naming its purpose, so a digest computed for one context can never be replayed as a digest for another.

Inside the payload: which trees were compared and how; the result block with status, complete, and exit_code; the summary counts; a documents array with one row per discovered document, its classification, and whether its content was available; the findings array; and the errors array of analysis errors the run kept. A repository path in any of these is a plain string when its bytes are valid UTF-8, and otherwise an object of the form {"bytes_hex": "…"} naming the raw bytes as lowercase hex; a writer never uses the object form for bytes that decode as text, so one path has exactly one spelling and every derived digest stays whole. Every finding carries its kind, its location with byte offsets, its attribution, the policy steps that set its final disposition, and the digests of the facts underneath it. The key_input that produced the finding’s identity is included, so an external system can recompute any finding’s identity from the report alone.

The envelope, down to its top-level keys:

{
  "schema": "amiss/scanner-report-envelope/v2",
  "compatibility": "experimental",
  "engine_digest": "sha256:…",
  "payload_digest": "sha256:…",
  "payload": {
    "evaluation": {},
    "result": { "status": "fail", "complete": true, "exit_code": 1 },
    "summary": {},
    "documents": [],
    "findings": [],
    "errors": []
  }
}

And one finding row from a real failing run, abridged to its skeleton:

{
  "kind": "explicit-target-missing",
  "attribution": "introduced",
  "effective_disposition": "fail",
  "location": {
    "path": "docs/src/introduction.md",
    "side": "candidate",
    "span": { "start_line": 49, "start_column": 1, "end_line": 49, "end_column": 38,
              "start_byte": 2912, "end_byte": 2949 }
  },
  "finding_key": "sha256:56a75485757d90b5959298c05f6b0531139b016533db320905ee532e5dd42512"
}

Findings are sorted by finding key, a domain-separated hash of kind plus scope. The human format prints the same facts in the same order, replaces every byte outside printable ASCII with a \uXXXX escape so a hostile filename cannot inject terminal control codes or a forged CI command into a log, and stops after two hundred findings. The JSON is never cut short: a serialized report that would cross the 64 MiB machine-json-bytes ceiling ends the run incomplete with OUTPUT_LIMIT_EXCEEDED instead of shortening the list, and the findings count has its own separate ceiling in Limits and refusals.

The schema for all of this ships in the repository under spec/, with canonical example files. The test suite validates the emitted bytes against that schema using an independent validator. The schema is the compatibility contract: fields appear, move, or change meaning only with a version bump, and the move from v1 to v2 is exactly one such change, the path union above. Every digest computed under a v1 preimage comes out identical under v2, because a text path serializes to the same bytes in both and the object form was not producible before, so the identities in old reports stay valid rather than orphaned. The move to v3 repeats the law for the identity: the host opens from a github.com constant to any declared spelling, the owner to slash-joined group paths, the evaluation names the recognition dialect in a nullable forge field, and the reference summary counts one same_repository total because a run has exactly one dialect. No v2 writer could emit any of that, so every inner digest keeps its meaning and its bytes; only the envelope and payload constants moved.

Limits and refusals

Every internal ceiling is a published number, and crossing one produces a typed error carrying the resource name, the configured limit, and the value that crossed it. Nothing hangs and nothing is quietly cut short; a run that could not finish its analysis always exits 2 with the crossing recorded in the report’s errors.

The scanning ceilings:

resourcelimit
documents per snapshot100,000
bytes per document4 MiB
document bytes per snapshot512 MiB
bytes per link destination16 KiB
parser nesting depth256
parser nodes per document250,000
parser nodes per snapshot5,000,000
references per document4,096
references per snapshot1,000,000
bytes per referenced target16 MiB
referenced target bytes per snapshot512 MiB
findings per complete run100,000
serialized report bytes64 MiB
analysis errors kept64

And the Git-reading ceilings:

resourcelimit
bytes per inflated object128 MiB
bytes per compressed stream256 MiB
compressed bytes per snapshot2 GiB
pack files4,096
bytes per pack index512 MiB
pack index bytes total1 GiB
delta chain depth128
index file bytes256 MiB
tree entries per snapshot1,000,000
bytes per path4,096

The charging rules keep every reported number reconstructible. Counters stop exactly one past the limit. Per-item byte limits report the declared size of the item. A snapshot-wide total reports the running total plus the first item that crossed it, and an item already rejected by its own per-item limit is never added to the total.

A crossing, as the report records it:

{
  "code": "RESOURCE_LIMIT_EXCEEDED",
  "phase": "git",
  "resource": "raw-path-bytes",
  "configured_limit": 4096,
  "observed_lower_bound": 5008
}

Both numbers travel with the error, so the reader knows how far past the ceiling the input went without rerunning anything.

Refusals follow one rule: when the input cannot be trusted, no output is produced to trust either. A base commit the store does not hold, a tracked file whose object is missing, an index with an unresolved merge conflict, a document whose bytes will not decode, a name outside the path grammar, a control file with a duplicated JSON key: each has a named error code (GIT_OBJECT_MISSING, DOCUMENT_INVALID, UNREPRESENTABLE_PATH, and the rest of a closed list), and each ends the run at exit 2. A name that is merely not UTF-8 is not on that list: it is an ordinary document whose path the report writes as hex. The alternative in every one of these cases is a report that looks complete and is not.

Security model

The repository being scanned is treated as the attacker. Its documents, paths, Git objects, packfiles, index, and policy file all came from whoever wrote the pull request, and the scanner’s whole job is to be a safe, pure function of those hostile bytes.

The engine executes nothing. No plugin system, no configurable commands, no formatter calls, no git subprocess. A policy file that names a command or a plugin is not a feature request to decline politely: the field is unknown, the configuration is invalid, the run ends incomplete, and there is no report to mistake for a pass. A pre-commit hook in this repository bans process::Command from the engine’s code outright, so the property is enforced when code is written, not just claimed afterward.

The engine never touches the network, and its dependency tree contains no networking library. It never writes to the repository, which the tests prove two ways: by comparing a full snapshot of the tree before and after every command, and by running the scanner against a tree it has no permission to write. Attempts to make it read outside the repository run into the never-follow-links rule described in Snapshots.

Parsers are the biggest attack surface and are treated accordingly. Every parser that consumes untrusted bytes has a fuzz target, a pinned upstream test corpus, and resource ceilings charged before and during parsing. A parser panic is caught, recorded as PARSER_PANIC against the document that caused it, and the run continues; a hostile document cannot take the scanner down. The two documents known to panic the pinned Markdown parser sit in the conformance corpus, and that classification is the contract’s answer to them.

Output is part of the surface too. Repository paths end up in terminals and CI logs, so the human format escapes every byte outside printable ASCII. An ANSI escape sequence, a carriage return, or a forged ::error:: workflow command embedded in a filename reaches the log only as harmless \uXXXX text, and a path that is raw bytes rather than text renders each such byte as the two-digit escape of its value, never inventing a character the bytes never encoded. The JSON report keeps fidelity its own way: a UTF-8 path is the exact original string, and a non-UTF-8 path is a bytes_hex object naming every byte, because the log needs safety and the report needs fidelity, and those are different channels with different rules.

The CI trust chain points the same direction. The GitHub Action tree that will eventually ship is validated as plain data by a separately installed wrapper, amiss-bootstrap, which then launches a verified engine with a cleared environment, fixed arguments, and a wall-clock timeout. The JavaScript launcher inside the action tree validates nothing: letting code that came with the tree decide whether the tree is valid would mean running the attacker’s code to check the attacker. Its only behavior is to refuse, with exit 2, if invoked directly.

Controls and policy

Two kinds of configuration can shape a run, and they carry opposite amounts of trust.

The repository policy is the one input read from the scanned tree itself, and it is correspondingly weak. .amiss/scanner-policy.json can add directories to scan, list protected paths whose removal is always a finding, and raise how severely a finding kind is treated. Raise only: record can become warn or fail, and nothing can go the other way. So a repository can make its own check stricter and can never loosen it. An unknown field makes the whole file invalid and the run incomplete, which is what keeps the policy from growing into a plugin system one field at a time.

External controls come from outside the repository, because anything stored inside it could be rewritten by the very pull request under review. The contract defines five: an organization floor (tightens ceilings and dispositions across many repositories), an adoption debt snapshot (a recorded list of known failures being worked off), a waiver bundle (time-limited permission to pass despite a named failure), trusted time, and an execution constraint. Each one is tied to the exact repository and tree it was issued for, host included: the v1 control formats can only spell github.com, so a control presented to a run whose declared identity names any other forge fails its binding the same way a wrong owner would. Presenting a control against a different tree is a CONTROL_BINDING_MISMATCH refusal, not a shrug. Waivers are the only sanctioned way to pass with a known failure, they expire, and every waiver that touches a finding appears as a visible step in that finding’s history. One asymmetry follows from the control formats’ own versioning: the report can carry a finding on a document whose name is raw bytes, but the waiver and debt formats still spell paths as text, so such a finding is reportable yet cannot be waived or adopted until those formats revise.

In the shipped v0 command line, all five external controls are none, and the report says so plainly: provider_verified is false, and each control’s absence is recorded. The delivery lane for verified controls, a provider-signed request format, is specified but not built. The report already has the fields so that adding the lane later breaks nothing. Until then, the honest reading of any local report is: these findings, under this policy, with no outside authority consulted.

The control-plane finding family closes the loop from the other side. When a candidate weakens its own policy file, shrinks what gets scanned, or edits the control configuration, the comparison itself raises policy-weakened, coverage-reduced, or control-plane-changed. Loosening the rules is reported under the rules being loosened.

Architecture

Six shipped crates, and trust flows in one direction; a seventh exists only for tests.

amisswireamiss-wirecanonical JSON, digests,schemas, machine contractsgitamiss-gitobject store, packs, index,no-follow handlesgit->wiremdamiss-mdpinned document parsersmd->wirescanamiss-scandiscovery, resolution,correlation, evaluation, policyscan->wirescan->gitscan->mdcliamissthe engine binarycli->wirecli->gitcli->scanbootamiss-bootstraptrusted CI wrapperboot->wireboot->git

amiss-wire is the foundation: strict JSON with canonical output, the digest rules, the report format, and every machine contract. Nothing in it knows what a repository is.

amiss-git reads Git storage behind the never-follow-links boundary: loose objects, packs, deltas, and the index, each under a parser that rejects malformed input and a published resource ceiling. It repairs nothing.

amiss-md holds the document parsers, pinned against the official CommonMark and GFM test suites plus the MDX grammar’s own tests. The pin is a checked-in manifest recording node counts, extraction results, and byte positions for every test case. A parser change that moves any of those moves the manifest, and review sees the diff.

amiss-scan is the evaluation itself: discovery, resolution, correlation, the base-versus-candidate comparison, policy, and report construction. It is a library that does no I/O beyond the store handed to it.

amiss is the binary: the closed command grammar, the in-process run, the two output formats. amiss-bootstrap is the CI wrapper that validates a pinned action tree as data and launches a verified engine. It is the one crate allowed to start a process, and the process it starts is the binary it just verified. A seventh crate, amiss-fixtures, exists only for tests: it writes hostile Git bytes straight into test repositories so the same fixtures exist on every platform.

Inside a run, the stages form a line:

pipelinesnapsnapshotsbase + candidatediscdiscoverysnap->discparseparse +extractdisc->parseresresolveparse->rescorrcorrelateres->correvalevaluate +policycorr->evalrepreporteval->rep

Every stage pays its resource budget before it works, every stage refuses rather than repairs, and the report at the end is a pure function of the two trees and the command line.

Edge cases and divergences

The edge cases in the suite are where the contract’s exact wording gets earned. A sample:

Paths are bytes, so été.txt resolves without any Unicode normalization, and two names that differ only in case are two different files even on a filesystem that would merge them. A directory link resolves the same way through a commit and through the staged index, which sounds obvious until you know the index stores no directory entries at all and containment has to be proved from sorted path prefixes. guide.md/ with a trailing slash fails as a type mismatch while guide.md resolves right next to it. %252F in a link decodes once, to the literal text %2F, and never becomes a second path separator.

Hostile input gets the same rules with more suspicion. A document path carrying ANSI color codes, a terminal bell, and a forged CI command prints harmlessly in the human output and survives byte-for-byte in the JSON. A tree entry named with bytes that are not UTF-8 is a scanned document whose path travels as hex, not a refusal and not a silent drop, while a name the path grammar itself rejects, a backslash or a dot segment, is an UNREPRESENTABLE_PATH refusal that disclosed those exact bytes. A five-thousand-byte path is a reported limit crossing carrying both numbers. A tracked file whose object is missing from the store refuses and names the document, instead of guessing about content it cannot see.

The forge dialects pin the URL spellings the forges’ own browsers emit and nothing looser. GitLab’s legacy pre-separator form still redirects in a browser and is foreign here, as is /-/raw/; a GitLab project literally named - could never be told apart from the separator, and GitLab reserves the name anyway. Gitea’s untyped src/<ref>/ form, which some tooling still generates, is foreign because the typed src/branch/ spelling is what the forge emits; a gitea tag link is out of version scope even when its segments spell the candidate branch exactly, because no tag is a trusted ref. And the line-anchor grammars do not leak: #L10-20 is a line reference only under the gitlab dialect, #L10-L20 only under github and gitea, so the same fragment can classify differently on two forges and each classification is that forge’s truth.

The parser pin records its known differences instead of hiding them. Measured against the pinned grammar bundle and against GitHub’s own rendering, exactly one difference affects link extraction: [link[^1]](#), a footnote call inside a link label, which the pinned Rust parser does not turn into a link. A reference written that way goes unseen. That is under-reporting, the safer direction to fail, and it is written down in the corpus notes rather than waiting to be discovered. Two upstream test documents make the parser panic; the engine catches both as PARSER_PANIC, and both live in the corpus as regression tests. The GFM spec text says ftp:// should autolink where the pinned bundle and GitHub’s renderer disagree; the bundle wins, and the corpus records why.

One more, for flavor: the object store re-hashes everything with SHA-1 collision detection, and the suite proves that the public SHAttered and Shambles collision files cannot even be framed as Git objects without breaking the very property the detector checks. Reachable in code, unconstructible in practice, and tested as such.

What Amiss is not

Amiss does not read your prose. A hash can prove that a file changed; it cannot prove that a sentence became false, or that anyone reviewed it. The investigation behind this tool spent a long time on designs that pretended otherwise, and the strongest lesson in the record is that observation, acceptance, review, and trust are different facts, and dressing one up as another rots all of them. So Amiss reports structure: this link resolves or does not, these bytes changed or did not, this paragraph moved or did not. Judgment stays with people.

It is not a link checker in the usual sense. Live-URL checkers query the network and decay with it; Amiss never touches the network and only ever speaks about one repository’s own files at two exact commits. It is not a style linter either: it has no opinion on headings, tone, or wording, and no rule engine to hold one.

It is not a documentation-coupling system with memory. Tools in that family, Fiberplane’s drift, Swimm, and the ledger design this project itself rejected, record what they blessed and react when reality drifts from the record. The rejected design is described in Provenance. The shipped scanner remembers nothing, which removes the migration, merge-conflict, and trust-on-edit failure modes wholesale, at the price of answering a smaller question.

It does not check heading anchors, site routes, code symbols, or other repositories. Each of those is a real check that belongs to a layer holding the right information: the site generator knows its routes, the language server knows its symbols. Amiss lists them as explicit unsupported boundaries in the report instead of guessing, because a guessed pass looks exactly like a real one until it burns you.

And it accepts no configuration that would let a repository weaken its own check. No suppression comments, no severity downgrades, no hooks. The absence is the point.

Against the alternatives

The project record compares the design against every neighboring school, and says where each one wins.

Swimm wins wherever docs are authored inside its platform: its auto-sync genuinely repairs a renamed token without a human. Amiss never edits prose, because the best published comment-updating system reached 16.7% exact match, and a tool that is wrong five times out of six does not get write access. Swimm sees only documents written in its format; Amiss reads the repository as it already is.

Fiberplane’s drift is the closest mechanism, and for a solo developer with twenty hand-placed anchors it is the right amount of tool: an authored @path#Symbol anchor is never a false positive. It checks only what someone annotated, though, and unannotated pages are its silent majority. Amiss starts from zero authoring and reports the whole surface, including the pages nobody thought to anchor.

The AI-rewrite agents win the pitch, because “we update your docs” sounds better than “we tell you what moved”. They lose everything that makes a gate: no coverage guarantee for the page the model never visited, nondeterministic output, and a tired reviewer as the only check on plausible wrong text. The honest relationship is composition: a deterministic finding queue is a good prompt feed for such an agent, and Amiss is deliberately the deterministic half.

Executable-docs systems (doctest and its relatives) prove more than Amiss does about the lines they execute, and nothing about any other line. Regeneration pipelines eliminate drift on derivable content and say nothing about hand prose; user zero’s stale-generator story in The evidence base shows regeneration passing forever on wrong output. Freshness dates are universal and free and gate on the calendar rather than on change. Each of these is a fine layer. None of them answers the question Amiss answers, and Amiss does not answer theirs.

The evidence base

The design was not reasoned from an armchair. Before any code, one real repository was audited end to end and used as the requirements generator. The project record calls it user zero: a Scala compiler with machine-checked proofs, three code-generation targets, a published docs site, 22 CI workflows, and roughly a dozen hand-built drift defenses already in place, transcluded snippets, executable CLI examples, five golden-file suites, a proof-extraction diff gate, a link checker. A repository that tries that hard and still drifts tells you what a tool must actually do.

The audit found seven live drift classes despite all those defenses. A few, concretely:

  • The architecture page said ten workflows; the tree had 22. It named a workflow file that had never existed. Three different module counts coexisted on one page.
  • The CLI reference omitted a public subcommand and documented a three-value exit-code contract while the code used four. The executable snippets on the same page were all green, because examples protect only the paths they execute.
  • A published OpenAPI file, claimed identical to compiler output, differed from the current golden by five stale lines.
  • The railroad diagrams regenerated on every docs build, from a second copy of the grammar embedded in the generator script by hand. Regeneration succeeded forever while faithfully reproducing a stale input. Freshness of a generation step proves derivation from the step’s input, not truth.
  • CLI transcripts kept passing against a stale compiled binary and only failed when someone rebuilt it. A green check against the wrong environment is worse than no check.
  • Hand-written counts (“ten workflows”, “23 theories”) were wrong in every place they appeared, because humans cannot maintain embedded aggregates.

Measurements set the tool’s scale expectations. Conservative discovery found 109 documents. Of 55 same-repository GitHub links, exactly two were broken, and the other measured explicit references all resolved under their real semantics. Replaying history showed the surviving reference graph would have produced 773 target-impact events across 393 first-parent commits, which is reviewer workload, not 773 defects, and it is why a file changing under an unchanged paragraph never blocks. And the experiment that shaped the architecture most: a single committed state file, updated from branches, conflicted in 0%, 18%, and 99% of trials as update counts per branch grew. That number is a large part of why the shipped scanner keeps no state at all.

Two observed conditions draw the sharpest boundaries. A page that was edited the day before the audit was already wrong when edited, so any scheme that trusts an edit blesses false prose. And every mechanism that let a person clear findings in bulk was, in the audit’s words, the gate’s cheapest bypass. Both conditions killed the ledger design described in Provenance, and both explain why the scanner only ever reports what two trees say.

Provenance

Amiss started life as a different tool. The original design was a review tracker with memory: a committed ledger of what had been checked, paragraphs trusted when edited, an ok command that recorded a human’s acceptance, and a refresh job that kept the ledger current. Point it at a repository and it would tell you which paragraphs had gone stale against the code they cited, and remember your answer.

The pre-implementation review killed that design, and the reasons survived a second, adversarial review. Identity rules that were safe for automatic observations were unsafe for recorded human attestations sharing the same storage. A committed ledger file conflicted between branches at rates that would have made merge queues the real product. Trusting a paragraph because it was edited blessed pages that were already wrong. And the deepest cut: the system’s central promise, that a recorded acceptance meant a human had checked the prose against the evidence, is not something any mechanism can make true. The project’s own experiments provided the counterexamples: of five replayed real-world cases, three had their paragraph edited while the reference stayed broken. The numbers and stories behind that verdict are in The evidence base.

What survived is the part that never needed memory. A link either resolves in a tree or it does not. Bytes either changed between two commits or they did not. A paragraph either moved with the file it cites or it did not. Those are pure functions of two snapshots. They need no ledger, no lock, no refresh, and no belief about anyone’s intent, and every one of the rejected designs had agreed on them. The v0 scanner is that surviving part, built alone, under the review’s discipline: fail closed, report every count, guess nothing.

The full investigation, market survey, adversarial reviews, experiment data, and the frozen contracts this implementation was built against live in the repository’s history as a complete dossier. The machine-readable contracts, schemas and canonical examples, ship in spec/. Where this book and the dossier disagree, the shipped code and its tests are the authority for what the tool does; the dossier remains the authority for why.

Development

The toolchain version is pinned in rust-toolchain.toml, unsafe is forbidden in every crate, and the lint table denies panics, lossy casts, wildcard matches, and undocumented errors. Hooks run through prek: formatting and the cheap checks on commit, then Clippy with warnings denied, the full test suite, cargo deny, and cargo shear on push. CI runs the same two hook stages, so passing locally and passing remotely are the same thing unless the hook table itself has a bug.

cargo nextest run --workspace
cargo clippy --workspace --all-targets -- -D warnings

Tests answer to a house rule called the teeth check: a test earns its place by failing when the code it guards is deliberately broken, and that mutation is performed before the test is trusted. A weekly mutation-testing job keeps the measured version of that claim honest. The parsers sit under a vendored test corpus, pinned by digest, whose manifest records node counts, extraction results, and byte positions for every case from the upstream CommonMark, GFM, and MDX suites; corpus/README.md documents every known difference. Each parser that takes untrusted bytes also has a fuzz target under fuzz/, with committed regression inputs and a nightly coverage-guided run.

The scanner runs on its own repository under --profile enforce in CI. This documentation passes through that same gate: every relative link in this book resolves in the tree, or the pull request that broke it fails.

Releases are automated. A bot keeps a release pull request current with the version bump and changelog; merging it publishes the crates, the version tag, and the GitHub release. Security checks are layered in CI as well: dependency update PRs with a cooldown, a weekly advisory re-check against a fresh database, CodeQL over both the Rust and the workflows, Scorecard, secret scanning with push protection, and build provenance attestations on release binaries.

Roadmap

The local command is the whole product today, and the boundaries it declares are the roadmap’s table of contents.

The nearest lane is verified external controls. The request wire, a signed envelope carrying the evaluation request and the five controls described in Controls and policy, is specified in the project record and deliberately unbuilt; the report already carries its fields as none. When those formats revise, they also pick up the report’s second-contract path union, closing the gap where a finding on a byte-named document is reportable but not yet waivable. When it lands, organization floors, adoption debt, waivers, trusted time, and execution constraints become usable in required checks, each tied to the exact tree it authorizes. The same revision is where controls learn hosts other than github.com: the scanner already evaluates any declared forge identity, but a control document can bind to one only when its format can spell it and a trust anchor exists there. The amiss-bootstrap wrapper and the release manifest chain, which validate a pinned action tree and launch a verified engine, ship in the repository now and wait on that same lane.

Reference support grows only where a boundary can be made exact. Heading-anchor resolution is the most requested candidate, and it enters only with a slug algorithm pinned the way the parsers are pinned, because a wrong guess about slugs is a false pass. reStructuredText and AsciiDoc parsers would slot in the way the MDX parser does today: pinned grammar, test corpus, honest opacity for whatever the grammar cannot see.

The governed layer, typed claims, acceptance records, verified review, everything the rejected predecessor wanted to be, stays closed behind the project record’s gates. The contracts exist and the semantics are worked out, but none of it is authorized until the scanner has earned adoption evidence and the storage experiments pass. The position has not changed: a check that cannot yet be made trustworthy does not ship wearing a trustworthy costume.

Performance work follows measurement. The published ceilings bound the worst case, a benchmark job watches the common case, and the known slow corners in the pinned upstream grammars are documented in the corpus notes rather than papered over.

The specified future, in claim kinds

The project record specifies a ladder of claim kinds beyond the v0 scanner, each named after the question it answers and each grounded in an observed failure from the evidence base. Deterministic kinds: a snippet claim (displayed code equals the selected source), a value claim (a number or name in prose equals a value extracted from code, for the hand-written counts that were wrong everywhere), an inventory claim (a documented set equals an extracted set, two-sided, because the audited page that listed only correct files still failed by omission), a tree claim, a graph claim, and a transcript claim (a recorded command run, fingerprinting the binary that produced it, because transcripts against a stale binary stayed green). Two review kinds sit above them: a narrative claim, free prose attested against fingerprinted evidence, and an external claim, prose that depends on the world outside the repository and re-checks on a schedule, the one place wall-clock time is a sanctioned input.

Extraction wider than explicit links is specified and deliberately deferred, because the noise classes are documented: prose that tells the reader to create a file matches every existing-directory heuristic forever, gitignored build artifacts look like broken references, and a bare filename that binds uniquely today becomes ambiguous the day a same-named file appears. Each of those has a worked answer in the record, and none of the answers is free, so none of them ships until the simple layer has earned its keep.

The enforcement model for all of it is tiered by proof strength, a lesson from the failure-mode study: hard blocks only for what is deterministically wrong, required human acknowledgment for impact, advisory discovery for everything heuristic, and no bulk acceptance gesture anywhere, because the audit’s cheapest-bypass finding applies to any gate this project ever ships.