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:
- Does every link or path in a document still point at something in the candidate tree?
- Did the content or file mode of a referenced file change between base and candidate?
- Did the paragraph holding the reference change too, stay exactly the same, disappear, or become impossible to match up without guessing?
- 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
gitcommand. 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:
| kind | meaning |
|---|---|
explicit-target-missing | a link or path points at nothing in the tree |
explicit-target-type-mismatch | it points at a file where a directory was written, or the reverse |
invalid-reference | the reference cannot be resolved as written, for example a path escaping the repository |
subject-changed | the paragraph holding the reference changed |
dependency-changed-subject-unchanged | the referenced file changed; the paragraph did not |
dependency-and-subject-cochanged | both changed together, the healthy case |
explicit-reference-removed | a reference that existed in the base is gone |
document-removed | a whole document left the tree |
unlinked-document | a document nothing links to and that links to nothing |
external-out-of-scope | a URL to somewhere Amiss does not check, counted and left alone |
opaque-html-region | raw HTML the parser cannot see into, size and place reported |
opaque-mdx-region | the same for MDX expressions and JSX |
unsupported-reference-semantics | anchors, site routes, symbols: real checks that belong to other tools |
unsupported-document-format | a document class Amiss knows it cannot parse |
unsupported-target-kind | the target is a symlink or a submodule, which Amiss will not follow |
unsupported-version-scope | a reference pinned to a branch or version the scan cannot vouch for |
unsupported-capability | a policy asks for something this version does not do |
observation-correlation-ambiguous | two matches were equally plausible, so no guess was made |
policy-weakened | the candidate loosens its own policy file |
coverage-reduced | the candidate shrinks what gets scanned |
control-plane-changed | the candidate touches the control configuration |
debt-worsened, debt-expired, waiver-invalid | the 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](&b) records both &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:
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 unchanged | dependency changed | |
|---|---|---|
| block unchanged | no finding | dependency-changed-subject-unchanged |
| block changed | subject-changed | dependency-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:
| resource | limit |
|---|---|
| documents per snapshot | 100,000 |
| bytes per document | 4 MiB |
| document bytes per snapshot | 512 MiB |
| bytes per link destination | 16 KiB |
| parser nesting depth | 256 |
| parser nodes per document | 250,000 |
| parser nodes per snapshot | 5,000,000 |
| references per document | 4,096 |
| references per snapshot | 1,000,000 |
| bytes per referenced target | 16 MiB |
| referenced target bytes per snapshot | 512 MiB |
| findings per complete run | 100,000 |
| serialized report bytes | 64 MiB |
| analysis errors kept | 64 |
And the Git-reading ceilings:
| resource | limit |
|---|---|
| bytes per inflated object | 128 MiB |
| bytes per compressed stream | 256 MiB |
| compressed bytes per snapshot | 2 GiB |
| pack files | 4,096 |
| bytes per pack index | 512 MiB |
| pack index bytes total | 1 GiB |
| delta chain depth | 128 |
| index file bytes | 256 MiB |
| tree entries per snapshot | 1,000,000 |
| bytes per path | 4,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.
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:
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.