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

Documentation drifts: code moves and the prose that points at it doesn’t. Tests catch the code half. The prose half sails through review, since a paragraph that didn’t change draws no eye. Amiss is the gate for that half. It compares two exact states of a repository, extracts the references its grammar supports from every document it discovers, resolves each against the tree, and reports what broke, what changed under unchanged prose, and what it could not check. It never reads meaning: it can’t tell you whether a sentence is true, and it doesn’t try.

Just want to run it? Quickstart goes from install to a green run on one page.

introductionbasebase commitreaddiscover documentsextract supported referencesresolve targetsbase->readcandidatecandidate commitor staged indexcandidate->readcomparecompare targetsand paragraphsread->comparereportreport findingsand visibilitycompare->report

Two closed sets draw the line. Which files count as documents is fixed by name: Markdown, MDX, AsciiDoc and reStructuredText by extension, six bare basenames like README, and two advisory files that carry no references; Discovery has the exact rows, and repository policy can bind one of the five built-in adapters to any path it names. Which references count is explicit link syntax only: bare path-like prose is never inferred, and a destination the tree alone cannot answer (a site route, a code symbol, a live URL, another repository) stays a visible boundary row instead of a guess; Resolution has those rows.

The four questions

A run takes a base and a candidate: two full commit IDs, or a commit and the staged index when you pass --index. Amiss answers four questions about them, and nothing else:

  1. Does every supported explicit reference still point at something in the candidate tree?
  2. Did the selected content or file mode of a referenced target 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 can’t handle is worse than no checker, since its green 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 is named there as unsupported instead of dropping out of it.

What a run never does

The scanner keeps no state. No baseline file, no cache, no database, nothing committed to your repository. Repository policy can expand discovery and raise three finding kinds; it can never lower a disposition or hide a finding. The one other file read from the tree, .amiss/router.yml, widens what a destination can resolve to and reaches only files the tree already holds. Provenance tells how the project arrived at that stance, and Controls and policy draws the exact boundary.

Each promise below is pinned by tests:

  • A check never writes. The no-write suite snapshots every byte under the repository root, .git included, runs five check invocations over it, and proves the snapshot unchanged; on Unix it also scans a fully read-only repository. The two verbs that do write, amiss fix and amiss adopt, touch exactly the paths their output names and nothing else.
  • It never runs repository code and never calls the git command. It reads Git’s objects, packs, and index directly through the repository reader.
  • It never follows symlinks while reading. Every file opens relative to a held directory handle with following disabled, and a link at the repository root, at .git, or anywhere along an object’s path is refused. The refusal is never confused with a missing file.
  • It never touches the network. The dependency gate bans the socket stack from the engine’s graph, so a missing object is a typed refusal, not a fetch.
  • The same repository, commits, and engine binary give the same report bytes, run after run, even across a repacked object store.
  • Resource ceilings have names and published values, all listed in Limits and refusals. A measured crossing produces a typed error naming the limit and the observed lower bound. Parser CPU spent before node accounting is a disclosed limitation in Security model, not covered by a stronger “nothing can hang” promise.

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.

Licensing

Amiss source code and documentation ship under the Functional Source License 1.1, ALv2 Future License. The repository’s third-party notices attribute the parser evidence, documentation assets, and fonts. Released Action trees carry the project license and a plain-text license bundle built from the locked dependency graph, and the release page carries the same bundle. The Gelasio webfonts this book serves are covered by the SIL Open Font License, and the notices mdBook embeds in generated JavaScript and SVG assets stay in the published site.

Last change: , commit: e840e6b9

Quickstart

Amiss checks the links and file references in your documentation against the git tree and fails the run when one stops resolving. This page goes from install to a green run. The Introduction says what the tool is and is not, and Invocation has the whole grammar.

Install from crates.io:

cargo install --locked amiss

Or download a binary from the release page. Each release ships amiss-linux-x86_64, amiss-linux-aarch64, amiss-macos-x86_64, amiss-macos-aarch64 and amiss-windows-x86_64.exe, plus THIRD_PARTY_LICENSES.txt, a SHA256SUMS file and its sigstore bundle; the amiss-probe-* files beside them are the external prober, not the CLI. The download arrives without the executable bit, and SHA256SUMS lists every asset, so the check skips the ones you did not fetch:

curl -sSLO https://github.com/HardMax71/amiss/releases/latest/download/amiss-linux-x86_64
curl -sSLO https://github.com/HardMax71/amiss/releases/latest/download/SHA256SUMS
sha256sum -c --ignore-missing SHA256SUMS
chmod +x amiss-linux-x86_64 && mv amiss-linux-x86_64 amiss

Put amiss on your PATH. On macOS, shasum -a 256 -c --ignore-missing SHA256SUMS is the same check. cargo binstall amiss does the download and rename for you, and gh attestation verify amiss --repo HardMax71/amiss (gh 2.49 or later) proves the file came from this repository’s release workflow; Security model has the CI form of that.

Now run it from the repository root. The command checks the staged index against HEAD, so it works on a depth-1 clone and on any repository with a commit. Before the first commit there is nothing to compare against, and the published hook passes with a note until there is one.

amiss check --repo . --object-format sha1 \
  --base "$(git rev-parse HEAD)" --index --profile observe

--object-format is sha1 for nearly every repository; git rev-parse --show-object-format prints yours. --base takes a full commit id, never a branch name, and HEAD~1 has no answer on a one-commit repository or a shallow clone, so the parent form is refused before the scan starts.

The first line is the verdict, and the rest is detail:

amiss: pass (fix 0, check 0, pre-existing 0, errors 0, exit 0)

A Fix is a reference this change broke, or one whose history the comparison could not decide, which blocks the same way; observe reports it without blocking, enforce fails the run on it. A Check is a file that changed under a paragraph that did not, listed for a person to read and never a verdict. Pre-existing is the backlog, the problems that were already there before this change. The same Fix counts under both profiles; the exit code carries the verdict. 0 means the run completed and nothing blocks, 1 means a finding blocks, and 2 means the run itself could not be trusted, so there is no verdict to act on. Profiles and findings lists every finding kind with its disposition, and Analysis errors every code behind that 2.

There is no ignore file, no exclude list, and no way to silence one finding. The nine skipped directory names are fixed (node_modules, vendor, third_party, dist, build, .next, target, test, tests), and a run always reads the whole repository, so a monorepo cannot scope it to one package. The ramp for a repository with a backlog is --profile enforce-introduced: what a change introduces blocks, the pre-existing rows stay warnings, and you work them off on your own clock. External http links are never fetched; the report lists them, and Amiss and link checkers shows the one pipe that hands them to lychee.

A first run can still report hundreds of missing targets against a tree nobody has broken. That happens when the site is built somewhere else. A generator’s configuration file in the tree is what selects its rule, so a repository that keeps only its documentation gets none, and every destination is read against the source files rather than the URLs the site publishes.

A repository can name its own router in one line. .amiss/router.yml says which router publishes the directory it sits in, and that directory is where the rule anchors:

router: directory-pages

That name is a shape rather than a generator: a site publishing every page at a directory of its own name and rewriting no destination, which Hugo, Jekyll, Eleventy and Astro all build by default. docusaurus, mkdocs, sphinx and zola are the other four names that turn a spelling on, and every router name is read for the second key below, whether it turns one on or not.

A second line says where that directory is published, and it is what answers the destinations opening with a slash:

router: astro
base: /

With that file under src/content/docs, /en/guides/astro-components/ is the .mdx of that name under it. A Starlight site needs no file, since its Astro configuration already says where its pages are and what they are served under. A route the tree cannot answer stays undecided, so the key adds no missing target.

The promise above holds. A declaration can move a destination onto a file the tree already holds, and it can never clear one the tree lacks, so nothing that is really missing goes quiet. Grafana keeps its Hugo configuration in a Docker image and a sibling repository. With that file under docs/sources its run reports 153 missing targets instead of 546, and every one of the 393 that go reaches a file the tree already holds. Adding the file is safe under every profile: both sides of a comparison are read under the routers the candidate declares, so the commit that writes one introduces nothing. Deleting one is reported at the file that held it. What a documentation router serves lists every name you can write and what each one turns on. A file that says anything else, such as a misspelled name or a base without its leading slash, stops the run with CONFIGURATION_INVALID at that file rather than declaring nothing in silence.

In CI the same engine ships as a GitHub Action that derives both commits from the event:

name: docs
on:
  pull_request:
  merge_group:
  push:
    branches: [main]
permissions:
  contents: read
jobs:
  amiss:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
      - id: amiss
        uses: HardMax71/amiss@v0
        with:
          profile: observe
      - if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: amiss-report
          path: ${{ steps.amiss.outputs.report }}
          if-no-files-found: ignore

The profile input defaults to enforce-introduced, which blocks what a change introduces and leaves the backlog as warnings; the snippet starts at observe so the first report can be triaged without blocking anyone. fetch-depth gives the checkout both commits the action compares: a pull request compares the merge commit with its first parent, a push compares the event’s before and after. The upload keeps the JSON report where a failed run can be read. Running it in CI has the direct form, GitLab, and the pre-commit hook.

Last change: , commit: e840e6b9

Documentation drift

Documentation drift is the disagreement that accumulates between a repository’s documents and its tree. The usual shapes: a link to a file that was renamed two months ago, a hand-written count (“ten workflows”) in a tree that has 22, a paragraph that kept explaining a function long after the function was rewritten under it. Nobody notices until a reader trusts the page and loses an afternoon.

Here is the smallest version of it. A pull request tightens a retry limit and renames the module, touching nothing under docs/:

--- a/src/retry.rs
+++ b/src/backoff.rs
@@ -1 +1 @@
-pub const MAX_ATTEMPTS: u32 = 3;
+pub const MAX_ATTEMPTS: u32 = 5;

The operations page keeps reading:

Retries are capped at three attempts; the limit lives in [`src/retry.rs`](../src/retry.rs).

The paragraph didn’t change, so nothing in review looks at it. Amiss does: the link’s target is gone from the candidate tree, which blocks under enforce. Change the constant in place instead and you get the warn: changed bytes under an unchanged paragraph. Neither finding says “three” is now a lie. That call belongs to whoever reads the finding.

The audit behind this tool went through one repository that took documentation seriously: golden files, executable CLI examples, a link checker, roughly a dozen hand-built defenses. It still held seven live drift classes. The architecture page counted ten workflows against 22 in the tree and named one that never existed. The CLI reference documented a three-value exit-code contract while the code used four. Railroad diagrams regenerated on every docs build, faithfully, from a stale copy of the grammar embedded in the generator script. The fresh output proved one thing: the stale input still compiled. Executable examples all stayed green, since examples protect the paths they execute and nothing else.

Checkers that run on demand inherit the failure they exist to catch, since the person who forgot to update the page also forgot to run the checker. Tools that rewrite prose to match the code make a different mistake: deciding what the code means is the one judgment a machine should refuse. So Amiss splits the work. The rewrites the engine can prove ship as fixes amiss fix applies byte for byte: a path off only by case from one tracked spelling, an anchor off by case or separator style, a claim expecting a line’s old text. Everything that needs judgment goes to someone who can be held to account: a person, or a coding agent reading the finding’s own description.

Every run compares two exact snapshots. Under enforce, a reference that stops resolving blocks the change that broke it. A referenced file that changed under an unchanged paragraph warns; Correlation and impact draws that boundary precisely. The code moving is a reason to reread the prose, not proof the prose is wrong. What the tool cannot see, it declares. And repository policy can raise severity but never lower it, so the gate survives the kind of change it exists to catch.

The full taxonomy of what a scan establishes is in Profiles and findings. What Amiss deliberately does not attempt, starting with reading your prose, is in What Amiss is not.

Last change: , commit: a5b41801

Invocation

Install from crates.io with cargo install --locked amiss, or take a prebuilt binary from the release page: the engine and the external prober for Linux x86_64 and aarch64, both macOS architectures, and Windows x86_64, beside THIRD_PARTY_LICENSES.txt, a SHA256SUMS file, and the sigstore bundle that attests it. cargo binstall amiss fetches the same binary. A download arrives without the executable bit and under its platform name; Quickstart has the four lines that verify, mark, and rename it. gh attestation verify <binary> --repo HardMax71/amiss, with gh 2.49 or later, matches a downloaded binary against the build that produced it.

The everyday form checks the staged index against the last commit. It names only HEAD, so it works on a depth-1 clone and on any repository with a commit. Before the first commit HEAD names nothing, and the published pre-commit hook passes with a note rather than refusing.

amiss check --repo . --object-format sha1 \
  --base "$(git rev-parse HEAD)" --index --profile observe

The commit-pair form compares two committed trees, the shape CI uses:

amiss check --repo . --object-format sha1 \
  --base "$(git rev-parse HEAD~1)" --candidate "$(git rev-parse HEAD)" \
  --profile observe

Both ids must be full commit ids: forty lowercase hex characters under sha1, sixty-four under sha256, never a branch name or a short form, and the two must differ. On a one-commit repository or a depth-1 clone, HEAD~1 names a commit the object store does not hold, so git rev-parse prints an error and echoes HEAD~1 back, and Amiss refuses that --base as an invalid invocation, grammar attached, before any scan starts. The staged form has no parent to miss.

The public command line is closed: the grammar below is everything, and anything else exits 2 as an invalid invocation. The verb comes first; after it the options come in any order, each at most once. Standalone --help prints this whole grammar on stdout, and <verb> --help prints that verb’s lines of it. A refused invocation prints one reason line per violated contract, each naming the option and the value it got, then the same grammar on stderr, so the binary teaches its own command line on either path. A machine format still writes its refusal envelope to stdout, where the error row keeps only the fixed sentence and the reason lines stay on stderr. The one exception is a malformed --format selection, which prints a single line, since the output channel itself was never agreed. The copy below is checked against the binary’s in CI.

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|bitbucket-cloud|bitbucket-data-center>]]
            --profile <observe|enforce-introduced|enforce>
            [--semantic-template <path>]
            [--explain-scope] [--format <human|json|sarif|codequality>]
amiss fix   --repo <path> --object-format <sha1|sha256>
            --base <full-oid> --index
            [--repository <host>/<owner>/<name>
             --ref refs/heads/<name>
             --default-branch-ref refs/heads/<name>
             [--forge <github|gitlab|gitea|bitbucket-cloud|bitbucket-data-center>]]
            --profile <observe|enforce-introduced|enforce>
amiss claim --repo <path> --path <repo-path> --line <n> --name <name>
amiss policy-include --path <repo-path> --suffix <suffix> --adapter <adapter>
                     [--repo <path> --object-format <sha1|sha256> --index]
amiss record-set --evidence <path>
amiss adopt --repo <path> --object-format <sha1|sha256>
            --base <full-oid> --candidate <full-oid>
            --repository <host>/<owner>/<name>
            --ref refs/heads/<name>
            --default-branch-ref refs/heads/<name>
            [--forge <github|gitlab|gitea|bitbucket-cloud|bitbucket-data-center>]
            --floor-digest sha256:<64-hex> --debt-owner <name>
            --debt-reason <text> --created-at <utc-instant>
            --expires-at <utc-instant> --debt-output <path>
amiss external-plan --report <path> [--format <human|json>]
amiss external-assess --plan <path> --evidence <path> [--format <human|json>]
amiss locale-assess --plan <path> --evidence <path> [--format <human|json>]
amiss locale-inventory --repo <path> --plan <path> --context <path>
                       [--format <human|json>]
amiss locale-plan --report <path> --context <path> --site <name> --channel <name>
                  [--scope-version <label>] [--fallback <class>] [--require-lineage]
                  [--format <human|json>]
amiss render --report <path>
             (--format human [--full] | --format <sarif|codequality|junit>)
amiss refs --report <path>
           (--target <repo-path> | --target-bytes-hex <lower-hex>)
           [--format <human|json>]
amiss --help
amiss --version

The table gives each flag in one line. The paragraphs after it carry the exact semantics; trust them when the short form reads ambiguous.

FlagValueRole
--repopaththe repository checkout to read; optional only for a policy-include row without an index preview
--object-formatsha1 or sha256the repository’s object format, sha1 for nearly every repository; git rev-parse --show-object-format prints it; paired with --repo and --index in a policy-include preview
--basefull commit IDthe state the comparison starts from
--candidatefull commit IDthe state under review; exclusive with --index
--indexnonechecks the staged state against the base, or selects it for a policy-include preview
--repository<host>/<owner>/<name>; owner and name lowercaseunverified identity claim for same-repository URLs
--refrefs/heads/<name>the candidate branch this tree belongs to; in the adopt form, also the ref the minted debt binds to
--default-branch-refrefs/heads/<name>which branch counts as default when resolving URLs
--forgegithub, gitlab, gitea, bitbucket-cloud, or bitbucket-data-centerURL dialect; an explicit flag beats the host table
--profileobserve, enforce-introduced, or enforcereport only, block introduced findings while carrying the backlog, or let every blocking finding gate; see Profiles and findings
--semantic-templatepathone strict, bounded, candidate-free semantic template for check; the scanner binds it to the exact commit or staged-index identity and the run remains self-asserted
--explain-scopenoneadds deterministic scope lines to human output; a machine format has no place for them and ignores it
--fullnoneprints every feedback item when replaying a report as human output; foreign to every other form and format
--formathuman, json, sarif, codequality, or render-only junitgrouped human items, the exact report in The report, or one of its CI projections; human output is bounded unless replayed with --full
--pathrepo-relative paththe file an authored claim pins, or the exact root of an authored suffix selector
--linepositive line numberthe line the claim expects, one-based
--nameASCII claim name, 1 to 120 bytesthe amiss: label; starts with a letter or digit, then letters, digits, ., _, -
--suffixdot-prefixed UTF-8 suffixthe exact 2 to 64 byte tail of an authored tree selector; no slash, backslash, or NUL; glob metacharacters stay literal and no normalization occurs
--adapterasciidoc, markdown, mdx, plain-advisory, or rstthe built-in grammar an authored selector binds to matching paths
--floor-digestsha256: and 64 hexthe organization floor the minted debt snapshot binds to
--debt-ownerteam:<name>, service:<name> or user:<name>the item owner the floor must authorize
--debt-reasontextwhy the debt is being recorded
--created-atUTC instantthe snapshot’s and items’ creation instant
--expires-atUTC instantwhen the items expire; must be after --created-at
--debt-outputpathwhere the minted snapshot is written; must not exist
--reportpaththe report file the plan, render, or refs form reads; foreign to every other form
--planpaththe plan file the assessment form judges; foreign to every other form
--evidencepaththe external observations external-assess judges, or the normalized specialist input record-set turns into a semantic template; foreign to every other form
--contextpaththe locale layout locale-inventory reads a tree under: each locale’s root, claimed locale, optional filename suffix, and the document suffixes that count as pages; locale-plan takes the producer and both locales from it; foreign to every other form
--siteartifact identitythe documentation site a locale-plan scope names
--channelartifact identitythe release channel a locale-plan scope names
--scope-versionlabelthe optional version a locale-plan scope names, compared exactly and never ordered
--fallbackartifact identitythe one fallback class a locale-plan allows on every source page, such as source-identical
--require-lineagenonemakes a locale-plan require exact lineage on every target-owned page
--targetrepo-relative paththe text path whose candidate references refs returns
--target-bytes-hexlowercase hexthe raw-byte path whose candidate references refs returns; exclusive with --target
--helpnoneprints the canonical closed grammar; stands alone, with no verb or other flag
--versionnoneprints this binary’s version and engine digest; stands alone, with no check and no other flag

--base and --candidate take full commit IDs: lowercase hex, forty characters for sha1, sixty-four for sha256. Branch names, short forms, and two equal IDs are refused. Amiss evaluates exactly the trees you name and resolves nothing for you. --index swaps the candidate for the staged state, including entries marked skip-worktree.

The identity group is a claim, not a login. --repository github.com/acme/widgets tells the resolver which same-repository URLs to read as this repository; nothing verifies you own it, so the spelling is strict. The host matches your documents’ URLs byte for byte and is never case-folded. Owner and name must be lowercase ASCII, so a workflow passing github.repository lowercases it first. Owner segments may nest, the GitLab group form; an effective github, gitea, bitbucket-cloud, or bitbucket-data-center dialect refuses a nested owner it could never match. A wrong spelling is refused, never rewritten. Without the identity group every absolute forge URL stays external, and a human run with a nonzero external count says so beside its totals rather than degrading silently.

--ref names the candidate branch for URL resolution only: no protected target branch, no --target-ref, and the report’s target stays null. No spelling of these flags turns a CLI run into a provider-authenticated one. A URL naming the declared default branch while another candidate is under test is recognized and reported as unsupported-version-scope, not resolved, and a human run counts those URLs beside its totals, since a pull request’s own branch passed as --ref makes one of every link to the branch it merges into. It also counts the URLs on the declared repository that spell a file in another dialect’s form, which a wrong --forge leaves external. A repository whose default branch was renamed lists the old names in its policy’s default_branch_aliases, and a URL naming one reads as the default branch; Controls and policy has the key. Full lowercase commit IDs must match --object-format and resolve only through that exact commit’s locally available objects. A fully walked tree may prove absence; an unavailable commit, tree, or target retains its exact ID and contained path as unsupported version evidence. A branch whose spelling is also a full ID is refused as ambiguous. Without the identity group, forge links stay external URLs and the report says so.

--forge names the URL dialect the resolver applies and accepts exactly five values. github covers GitHub and GitHub Enterprise, gitlab the /-/blob/ separator form, gitea the form Gitea, Forgejo, and Codeberg share, and bitbucket-cloud the Cloud /src/<commitish>/<path> form. bitbucket-data-center covers project and personal browse routes whose revision is carried by the query; it is always explicit because Data Center has no canonical host. Without the flag, github.com, gitlab.com, codeberg.org, and bitbucket.org select their own dialects; an identity on any other host is refused as INVALID_EVENT until the flag names its dialect, since accepting it would silently leave every same-repository link external. An explicit flag beats the table; that’s how a self-hosted instance gets its grammar. Recognizing a dialect authenticates nothing about how the run was invoked.

--semantic-template gives check one candidate-independent semantic producer result, such as a complete record-set@1 inventory. The file follows the semantic-template schema, is capped at 16 MiB, and cannot name a candidate or source report. The scanner waits until it has resolved the exact commit tree or pinned staged-index projection, binds the template to that candidate identity, and then applies the same compiled consumers and limits as sealed evidence. Malformed, oversized, or consumer-invalid input ends the run incomplete. The path is admitted only by check: fix, adopt, authoring, and report-only commands refuse it. Because the caller chose the file, the report still says sandbox.assurance: self-asserted; this flag never enters the provider-authenticated controls lane.

--format json prints the exact report in The report, one line plus a trailing newline. sarif and codequality project the same report for code-scanning uploads and GitLab merge-request widgets. A refused invocation still emits a refusal envelope under json and sarif, and an empty array under codequality, so a consumer never parses half a document. JUnit is deliberately absent from check: it can only reopen a validated report through render.

human is the default. It prints a status header, one error row per retained analysis error, then at most ten grouped Fix and Check rows, each naming a target and an affected-place count, with an overflow line when more exist, then at most ten Pre-existing rows under their own window. Where every place under a row carries the same finding kind and the same reason the target did not answer, the row states both after its count, such as path-not-found with the nearby spelling it almost matched, and each place below it is one line naming the document with its line and column. Where the places disagree the row stops at the count and every place spells its own kind and reason. A row shows ten places and counts the rest, unless the report is replayed with --full. Then one fixed note sentence per finding kind the feedback carries, in the wording from Profiles and findings, and one per error code, in the wording from Analysis errors. Three totals lines close the output, and a records line names the record-only kinds and their counts when the run has any, where a row folding several references counts each of them. Pre-existing rows are the backlog at warn or fail, and the backlog keeps its own window, so introduced volume cannot push it off the terminal. The full findings stay in JSON. --explain-scope adds six fixed scope lines to that human output, two naming this run’s counts, and one row per reason a reference was declined, largest count first. A reference is declined when the tree holds no answer for the destination, so the rows come to the same total as the run’s unsupported-reference-semantics and unsupported-target-kind records. Each row spells the resolution and its reason the way the report spells them, and Resolution says what each reason means. None of this changes JSON, behavior pinned by the CLI tests.

amiss render --report <path> --format <human|sarif|codequality|junit> reopens one JSON report and emits an alternate projection without reading the repository or evaluating it again. It accepts only the active report envelope, supported wire compatibility, matching payload digest, and a consistent recorded result. A successful projection exits with that recorded 0, 1, or 2; a closed stdout does not change it. Invalid, unreadable, or oversized report input exits 2 without output. JSON is not an admitted projection because the report file is already canonical JSON; requesting it is a grammar refusal and may emit the standard incomplete JSON refusal envelope. Human replay additionally accepts --full, which prints every Fix, Check, and pre-existing item in the report’s canonical order without overflow lines. It changes no facts, totals, notes, or exit class; the ordinary human projection keeps the two independent ten-item windows.

JUnit emits one deterministic suite. Each finding is one case named by its kind and stable finding key: effective fail is a failure, while warn and record remain passing cases whose disposition and description ride in system-out. Each retained analysis error is an error case. A report with no rows gets one passing report case so CI dashboards retain the artifact. File locations ride only when their exact text can round-trip through an XML 1.0 attribute, and every duration is zero because the canonical report records no timing. The XML cannot change the recorded verdict; GitLab likewise treats a JUnit artifact as display data rather than the job result.

Exit codes are three classes, not detail. 0 means the run completed and nothing blocks. 1 means a finding blocks. 2 means nothing trustworthy could be produced. A consumer that closes the pipe early, head among them, ends the printing and not the verdict.

amiss fix repairs what the check proves, over the staged state only. It runs the same evaluation as check --index, takes every finding whose fix is not null, and rewrites exactly those byte spans in the working tree. Under --profile enforce-introduced it takes only the findings that profile charges to the change, so a pre-existing row’s fix waits for a run under enforce, and the summary line counts the ones it left. Nothing is applied on faith. The staged index is pinned before the evaluation and verified unchanged before any write. A document is repaired only while its working-tree bytes still equal the staged bytes the fixes were computed against, and one already holding the repaired bytes counts as already fixed. A CRLF checkout counts as those bytes too: under core.autocrlf or eol=crlf a staged document with no carriage return is written with every line ending as CRLF, so the repair is applied to the staged bytes and written back the same way. A mix of endings is not a checkout and is refused. A document is refused whole when it is missing from the index, a symlink, unreadable, escaping the worktree, differing from its staged bytes, carrying overlapping or out-of-range spans, or failing the write; each refusal row names its reason. Output is one row per document and a summary line, so --format, --explain-scope, and --candidate are refused rather than ignored. Exit 0 means every carried fix was applied or already present. Exit 1 means a document refused, or the staged index moved mid-run with nothing applied. Exit 2 means the evaluation could not be trusted or the staged index could not be read; either way nothing was touched. Restage and rerun amiss check to see the repaired state judged.

amiss claim authors a value claim and reads no git at all. Give it a repo-relative path and a one-based line; it reads that line from the working tree and prints one ready definition to stdout, nothing else, so the output pastes or pipes straight into a document. It proves before printing: the candidate definition is run back through the markdown extractor and the claim grammar, double-quoted first, single-quoted when that round trip fails, and double-quoted with its backslashes, double quotes and ampersands escaped when a line holds both quotes or an HTML entity a title would decode. A line no spelling carries is refused rather than printed broken. Exit 0 prints the definition. Exit 1 refuses the file or the line: unreadable, past the end, not UTF-8, or numbered beyond the platform. Exit 2 is an invalid invocation, which is also where a --name outside its grammar or a --path carrying reserved bytes lands.

amiss policy-include authors the one exact root-and-suffix selector from repository policy. Without the optional group it prints one canonical JSON include row to stdout, ready to insert into the policy’s sorted document_includes array. The row is built and accepted by ScannerPolicy before it is printed, so the helper has no parallel path, suffix, adapter, or canonicalization grammar. It never reads or edits an existing policy and therefore cannot merge, replace, broaden, or reorder repository controls.

Adding --repo, --object-format, and --index together switches the output to one canonical JSON array of the current stage-zero paths that the selector matches, in raw Git path order. The preview uses the scanner’s production suffix matcher, its repository/index reader and ceilings, and an end-of-read index identity check. A path that is not UTF-8 uses the report’s existing {"bytes":[...]} form. This previews selection only: a built-in document classification still wins its adapter, and a later scan can still reject an unavailable object or unsupported entry kind. The three preview flags are one group; partial groups and every unrelated option are invalid invocations. Exit 0 wrote the row or complete preview, exit 1 means the repository, index, or output was unavailable, and exit 2 means the closed invocation or selector grammar was invalid.

amiss record-set turns one strict record-set input into the candidate-free semantic template accepted by amiss check. The input names the specialist producer, its producer-defined context and input digests, completeness, one stable set name, and key-sorted unique rows. Amiss applies the scanner’s exact key/value bounds, fixes the semantic producer contract to record-set@1, runs the result through the checked template writer, and prints canonical JSON plus one LF. It does not run the specialist, inspect a repository, recompute or authenticate the specialist-defined digests, or elevate the output above self-asserted evidence. Exit 0 wrote the template, exit 1 means the input or output was unavailable or invalid, and exit 2 means the closed invocation was invalid.

amiss adopt onboards a repository that already has drift. It runs the evaluation under enforce, accepting no --profile, and mints a debt snapshot from every blocking finding of the two debt-eligible kinds, explicit-target-missing and explicit-target-type-mismatch. Gate new drift today; work the recorded backlog off on its own clock. The engine supplies each item’s key and accepted fact from the evaluation. The flags supply what it cannot know: the floor digest the snapshot binds to, the owner that floor must authorize, the reason, both instants in the wire’s own clock grammar with creation strictly before expiry, and the output path, which must not exist. An all-zero floor digest or an expiry the local clock has already passed records nothing, since that debt could never apply. Adoption records a committed tree, so --index is refused and the identity triple is required. --ref does double duty here: beyond URL resolution, its exact string becomes the snapshot’s ref binding. Spell it as the branch the consuming lanes enforce, since a snapshot bound to another ref stays out of scope there; the check form is untouched by that reuse. Consumption is narrower than minting: only the sealed bootstrap that the provider lanes operate feeds a snapshot back to the engine, while the public check form and the convenience Action supply every control as absent and never read one. On a repository without a lane the minted file waits, and the working ramp for a standing backlog is enforce-introduced. The minted file is written only after the engine’s own reader accepts its bytes, by exclusive creation. The summary line counts what was recorded, what blocked but is not debt-eligible, what was eligible but missing facts, and what was eligible but names a path outside the text path grammar, which the debt file cannot hold. A second line names the next step, since nothing public reads the file. Exit 0 recorded the snapshot. Exit 1 means the output path already exists or the write failed, any partial file removed. Exit 2 means nothing trustworthy could be recorded: the evaluation failed, the report carried no candidate tree, or the minted bytes failed the engine’s own reader.

amiss external-plan derives the external plan from a report file a check --format json run already wrote. It opens no repository and touches no network: it verifies the report’s own payload digest, refuses an incomplete report, and projects the delegated-evidence delta the report already carries. --format takes human or json here; SARIF, Code Quality, and JUnit remain report projections and are refused here. Exit 0 wrote the plan. Exit 2 means the input could not be trusted: unreadable, larger than a scanner report can be, not the scanner’s valid JSON, not a report envelope, digest mismatch, incomplete, or carrying a malformed eligible occurrence.

amiss external-assess judges an external plan against one producer’s observations, writing the external assessment offline under the engine’s fixed policy. It verifies the plan’s digest and the evidence’s binding to that exact plan; evidence naming a destination the plan did not introduce, repeating one, or binding another plan refuses the whole run. --format takes human or json. Exit 0 wrote the assessment, refuted rows included, since the artifact is advisory data. Exit 2 means an input could not be trusted.

amiss locale-assess judges a locale coverage plan against one producer’s page inventories, writing the coverage assessment offline under the engine’s fixed policy. It verifies both digests and the evidence’s binding to that exact plan; evidence from another plan, producer, docs candidate, or locale scope refutes or leaves the audit unproven rather than refusing it. --format takes human or json. Exit 0 wrote the assessment, missing pages and refuted rows included, since the artifact is advisory data. Exit 2 means an input could not be read, parsed, or trusted.

amiss locale-inventory reads the locale page inventory pair straight out of a checkout, writing locale coverage evidence for the plan given to it. The plan names the object format, the commit, and the tree, so the verb takes no --candidate and no --object-format: it walks exactly the snapshot the plan binds and refuses when the checkout does not hold it. --context carries the layout, since a repository holds no record of which subtree is which locale. --format takes human or json. No file content is read, only the tree, so a page’s digest is the blob its path resolves to. Exit 0 wrote the evidence, missing and untranslated pages included, since the artifact is advisory data. Exit 2 means an input could not be read, parsed, or trusted.

amiss refs asks a complete validated report which candidate occurrences refer to one repository path. It opens no repository and does not reinterpret prose: an occurrence matches when its normalized repository intent, resolved or unresolved path, resolved target, or known version-scoped path is the exact target. Correlation alternatives are included, so ambiguous pairing cannot hide a candidate occurrence. Human output names every document, source position, construct, resolution, and observation ID. JSON output is an array of the unchanged candidate Occurrence objects already defined by the report schema, not a new report or wire envelope. --target-bytes-hex makes raw non-UTF-8 Git paths queryable on every platform. A valid empty answer exits 0 even when the source report recorded blocking findings, and an empty answer for a target holding # says on stderr that a fragment belongs to a reference and not to the path; incomplete, malformed, unreadable, oversized, or digest-mismatched reports exit 2. Querying writes no state and changes no recorded verdict.

amiss --help projects the exact Rust-owned grammar above, followed by one newline, and exits 0. It opens no repository and accepts no verb or second token. A combined, duplicated, or misspelled help flag is an ordinary refused invocation; selecting a machine format still uses that format’s existing refusal envelope.

amiss --version also stands alone: any second token makes it an ordinary, refused invocation. It opens no repository. It prints two lines and exits 0:

amiss <version>
engine sha256:<64 hex digits>

The first line is the binary’s version. The second is the engine_digest, computed by hashing the executable’s own bytes: the same digest every report stamps and the release manifest pins per platform. So an installed binary matches a release row, and a report matches back to the binary that produced it, without running a scan. A binary that cannot read its own file prints engine unavailable and still reports its version. The other shipped binaries answer --version with one line each: amiss-bootstrap, amiss-manifest, amiss-constraint, amiss-probe, and the three provider services.

Last change: , commit: e840e6b9

Profiles and findings

A finding is one fact the scan established, and four of its parts carry the story. 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. A failing reference that the correlator pairs with a base occurrence failing the same way stays pre-existing when its block is reworded, its document is renamed, or its target is edited; it stops being pre-existing only when the failure changes shape or count. The disposition says what the run does about it, and it comes twice on every row: configured is what the rules asked, effective is what happened, and only the effective one decides the exit. record is noted, warn is shown, fail blocks. The location says where, down to byte offsets. The full row carries more, twenty-one members; The report holds the shape.

The profile picks the built-in disposition for each kind. Seven kinds flip between the columns: the three structural reference failures, both value-claim kinds, projection drift, and a site-build defect warn under observe and fail under enforce. Six control kinds and unsupported-capability fail under both profiles, one kind warns under both, and the remaining thirteen are records. The exact table below copies FindingKind::built_in_disposition, and CI checks the two stay equal.

enforce-introduced is the ramp between the two. It applies the enforce column, and after repository policy and any floor have raised what they raise, it lowers every failing finding whose attribution is pre-existing to warn, writing a scanner-policy-defaults/<kind>/enforce-introduced step into the row’s trace. Configured stays fail; effective becomes warn. The backlog stays visible and counted while anything the comparison introduced still blocks. An attribution the engine cannot establish keeps its enforce disposition. And an organization floor whose minimum is enforce does not merely refuse the ramp: the run ends incomplete at exit 2 with a control-binding mismatch.

Finding kindObserveEnforce
explicit-target-missingwarnfail
explicit-target-type-mismatchwarnfail
invalid-referencewarnfail
target-declared-untrackedrecordrecord
unsupported-reference-semanticsrecordrecord
unsupported-document-formatrecordrecord
unsupported-target-kindrecordrecord
unsupported-version-scoperecordrecord
unsupported-capabilityfailfail
dependency-changed-subject-unchangedwarnwarn
dependency-and-subject-cochangedrecordrecord
subject-changedrecordrecord
translation-driftwarnwarn
explicit-reference-removedrecordrecord
document-removedrecordrecord
opaque-mdx-regionrecordrecord
opaque-html-regionrecordrecord
observation-correlation-ambiguousrecordrecord
unlinked-documentrecordrecord
site-build-defectwarnfail
policy-weakenedfailfail
coverage-reducedfailfail
control-plane-changedfailfail
debt-worsenedfailfail
debt-expiredfailfail
waiver-invalidfailfail
claim-brokenwarnfail
claim-target-missingwarnfail
projection-driftwarnfail

What each kind means

One fixed sentence per kind, copied from FindingKind::meaning and checked against it in CI. The machine report carries the same sentence on every finding row, so this page is a reference, not a second source of truth.

  • explicit-target-missing: a reference names a repository path, a line range inside one, or a heading anchor no known renderer publishes; restore the target or correct the link
  • explicit-target-type-mismatch: the referenced path exists as a different kind than the reference promises, as when a trailing slash names a regular file; make the spelling match the target
  • invalid-reference: the destination cannot name a target: it escapes the repository, carries a backslash, an encoded separator, or control bytes, or is a URL that is not well formed; fix the destination
  • target-declared-untracked: a tracked ignore file says the repository does not keep the path this reference names, either by naming that path or by emptying the directory the file sits in, so no tree can answer for the link; the reference is recorded and counted, never cleared
  • unsupported-reference-semantics: the reference uses semantics this run did not evaluate: a site route, a protocol-relative destination, a query string the selected grammar does not recognize, a destination that needs a document attribute this run does not evaluate, the repository root, which every snapshot holds, or a fragment on a target it cannot answer for; the unchecked part is declared instead of guessed
  • unsupported-document-format: a document this run discovered was not scanned: no parser for its markup, a policy include with no grammar bound, bytes that do not decode as the format requires, markup its grammar refuses, or a per-document ceiling crossed; the document row’s reason says which, and the file’s references are never checked
  • unsupported-target-kind: the reference resolves to a symlink or submodule, or to a path beneath one, which Amiss does not follow; the boundary is declared instead of crossed
  • unsupported-version-scope: a forge URL names this repository at another named version or an exact commit whose required objects are unavailable; use the candidate ref, or make the exact commit available
  • unsupported-capability: a candidate document declares a reserved amiss: capability this engine does not implement; the run ends incomplete rather than guessing at the claim
  • dependency-changed-subject-unchanged: the referenced content changed and the block citing it did not; a reason for a person to reread the prose, never a machine verdict that it is wrong
  • dependency-and-subject-cochanged: the referenced content and the block citing it changed together, the shape of a maintained page; recorded with nothing to act on
  • subject-changed: the block holding the reference changed while its target did not; recorded so prose moving over an unchanged dependency stays visible
  • translation-drift: a declared translation fell behind its source: the source page changed while the translation stayed byte for byte, or the translation left while its source stayed; a reason for a translator to look, never a verdict that the translation is wrong
  • explicit-reference-removed: a reference that existed in the base is gone from the candidate; the removal is recorded as a fact, never treated as evidence that the edit was wrong
  • document-removed: a scanned document left the tree; recorded so the disappearance is a stated fact rather than a silent one
  • opaque-mdx-region: an MDX expression region the parser cannot see into; a reference inside it is a stated blind spot, reported with size and place
  • opaque-html-region: a raw HTML region the parser cannot see into; a reference inside it is a stated blind spot, reported with size and place
  • observation-correlation-ambiguous: an occurrence has more than one plausible counterpart across the comparison; Amiss never chooses by input order, so the match is recorded as undecided
  • unlinked-document: a scanned structured document inside a complete site build’s source root is unreachable from every rendered navigation entrypoint; link the page from rendered navigation or keep non-page material outside that root
  • site-build-defect: a complete site build reports a route with conflicting owners or a redirect whose declared terminal route or anchor is not uniquely published; repair the route table or its available routing source
  • policy-weakened: the candidate loosens its own repository policy, dropping an include, a protected path, a projection assertion, a router declaration, or a raised disposition; loosening the rules is reported under the rules being loosened
  • coverage-reduced: a protected path is gone or not a scannable document while its protection stands; restore it or amend the protection in a reviewed change
  • control-plane-changed: a floor-protected control path is not the identical present blob on both sides, in mode and content; the floor exists so control edits are always visible
  • debt-worsened: the finding an accepted debt item names no longer matches the recorded fact; debt tolerates exactly the recorded state, so any drift fails
  • debt-expired: trusted time reached a debt item’s expiry while its finding persists; fix the finding or renew the debt in a reviewed change
  • waiver-invalid: a waiver item cannot apply, expired against trusted time or issued outside the floor’s authority; an invalid waiver suppresses nothing
  • claim-broken: a value claim’s target line no longer says what the document claims it says; update the claim or the target so the two agree
  • claim-target-missing: a value claim names a target line no regular file in the candidate can answer; point the claim at a tracked file and a line inside it
  • projection-drift: a policy-owned projection cannot prove that its visible code block equals its selected repository source; restore its unique sink and source or make their projected bytes agree

Before and after

Only the shown state changes. Floor, debt, waiver, and trusted-time examples use the control API described in Controls and policy.

Finding kindBeforeAfter
explicit-target-missingdocs/index.md: # Index; docs/missing.md is absent.Append [missing](missing.md) to docs/index.md; the target remains absent.
explicit-target-type-mismatchdocs/index.md: # Index; docs/guide.md is a regular file.Append [guide](guide.md/); the trailing slash promises a directory.
invalid-referencedocs/index.md: # Index.Append a link whose destination is ../../etc/passwd, which escapes the repository from docs/.
target-declared-untrackeddocs/index.md: # Index; docs/settings.md is absent and docs/.gitignore contains /settings.md.Append [settings](settings.md) to docs/index.md; the target stays absent and the declaration stands.
unsupported-reference-semanticsdocs/index.md: [setup](guide.md); docs/guide.md exists.Change the link to [setup](/docs/guide.md); a leading slash names a site route, which no tree can answer.
unsupported-document-formatnotes/plan.org is absent.Add notes/plan.org containing * Plan; .org is discovered and has no parser.
unsupported-target-kindalias is a Git symlink; docs/index.md has no link to it.Append [alias](../alias); Amiss will not follow the symlink.
unsupported-version-scopeRun with forge github, repository github.com/acme/widgets, candidate ref refs/heads/feature/x, and default ref refs/heads/main; the link names blob/feature/x/docs/guide.md.Keep that identity context but change the link to name blob/main/docs/guide.md.
unsupported-capabilitydocs/claims.md: # Claims.Append [amiss:foo]: <amiss:reference/path-exists?path=docs/a.md>.
dependency-changed-subject-unchangeddocs/guide.md: See [parser](../src/parser.rs).
src/parser.rs: tokenize()
Leave docs/guide.md unchanged.
Change src/parser.rs to lex().
dependency-and-subject-cochangeddocs/guide.md: See [parser](../src/parser.rs).
src/parser.rs: tokenize()
docs/guide.md: See [revised parser](../src/parser.rs).
src/parser.rs: lex()
subject-changeddocs/guide.md: See [parser](../src/parser.rs).
src/parser.rs: tokenize()
Change the paragraph to See [revised parser](../src/parser.rs).
Leave src/parser.rs unchanged.
translation-driftRepository policy pairs docs with docs/de; docs/guide.md and docs/de/guide.md both exist.Add a paragraph to docs/guide.md and leave docs/de/guide.md unchanged.
explicit-reference-removeddocs/guide.md has separate [parser](../src/parser.rs) and [lexer](../src/lexer.rs) paragraphs.Remove only the parser paragraph; both targets and the lexer paragraph remain.
document-removeddocs/obsolete.md contains # Obsolete.Delete docs/obsolete.md.
opaque-mdx-regionpage.mdx: [Parser](src/parser.rs).Append <Note>{"hidden"}</Note>.
opaque-html-regionpage.md: [Parser](src/parser.rs).Append a separate <div class="card">hidden</div> block.
observation-correlation-ambiguousdocs/guide.md: Old [parser](../src/parser.rs).Replace it with two paragraphs: First [parser](../src/parser.rs). and Second [parser](../src/parser.rs).
unlinked-documentComplete site-build evidence proves every scanned source beneath docs/ reachable from its rendered homepage.Add docs/orphan.md without adding a rendered navigation path to it.
site-build-defectComplete site-build evidence maps /old/ to the unique published route /guide/.Change its attributed redirect rule to target absent route /missing/.
policy-weakenedRepository policy sets explicit-target-missing to fail.Remove that finding_dispositions entry.
coverage-reducedRepository policy protects docs/required.md, which contains # Required.Keep the inventory obligation and delete docs/required.md.
control-plane-changedA verified floor protects .github/workflows/scan.yml, whose content is on: push.Keep the floor and change the protected file to on: pull_request.
debt-worsenedVerified debt accepts one occurrence of see [gone](missing.md).Keep the debt item and duplicate that occurrence, changing the finding fact.
debt-expiredDebt expires at 2026-07-10T00:00:00Z; trusted time is 2026-07-09T00:00:00Z.Keep the finding and debt unchanged; trusted time advances to 2026-07-10T00:00:00Z.
waiver-invalidWaiver expires at 2026-08-01T00:00:00Z; trusted time is 2026-07-12T10:00:00Z.Keep the finding and trusted time unchanged; set expires_at to 2026-07-10T00:00:00Z.
claim-brokenCargo.toml line 3 is version = "0.16.0" and a claim expects exactly that.Bump line 3 to version = "0.17.0" and leave the claim unchanged.
claim-target-missingA claim names Cargo.toml line 3, which exists.Delete Cargo.toml or point the claim at line 9999.
projection-driftA policy assertion selects examples/request.json lines 1 to 12, and the adjacent code block has the same projected bytes.Change the selected lines without updating the visible code block.

The control families exist so that loosening the rules and leaning on an invalid waiver are themselves visible findings. Repository policy may raise only explicit-target-missing, explicit-target-type-mismatch, and invalid-reference, as the policy parser and evaluator enforces. A rule naming a lower disposition is a no-op, and dropping one the base carried is policy-weakened. Repository policy has no suppression syntax at all. The only lowerings anywhere are the ramp above, a verified debt item, and a verified waiver, each leaving a trace step and none removing the row. The way to remove a repository-policy finding is to fix what it points at.

Last change: , commit: 60aacafb

Analysis errors

An analysis error is not a finding. A finding is something the scan established about the repository. An error means the scan itself could not be trusted, so the run ends at exit 2 with no verdict, and the machine report arrives marked incomplete with one row per retained error.

Every row names its code, the phase it came from (invocation, configuration, git, discovery, parse, resolution, policy, output, or internal), and the fixed sentence below. A row that knows a file carries its path, or its raw bytes as an array of numbers when the name is outside the path grammar, and a ceiling crossing carries the resource, the configured limit, and the observed lower bound. A JSON input that fails its strict reader, such as the repository policy, also carries json_path, the member the defect sits at, as in $.document_includes[0].kind, and the human error line prints it after the file. The report has the exact shape. The human output prints the same sentence as a note line once per code, so an exit-2 log says what to do without this page open.

Most codes name something to change: a policy file, the command line, the index, a control bound to another repository. Seven point at Amiss instead. INTERNAL_ERROR, RESOLUTION_ERROR, PARSER_ERROR, PARSER_PANIC, INVALID_SOURCE_SPAN and SANDBOX_VIOLATION are engine defects, and the next step for each is a bug report. REPORT_CONSTRUCTION_FAILED sits beside them with one thing to rule out first, since the report is written to a stream and a stream that will not take the bytes fails the same way.

Two codes come from the ceilings, RESOURCE_LIMIT_EXCEEDED and OUTPUT_LIMIT_EXCEEDED. No option raises a ceiling, so the two numbers in the row are the whole answer: the input is past what one run reads or what one report holds. Limits and refusals lists every ceiling and the measurements behind the three that moved.

DOCUMENT_INVALID is the one code that does not end a run. A document whose bytes will not decode says nothing about the rest of the tree, so that file is reported unsupported, an unsupported-document-format finding records that its references went unchecked, and the scan carries on.

What each code means

One fixed sentence per code, copied from AnalysisErrorCode::meaning and checked against it in CI. The machine report carries the same sentence on every error row, so this page is a reference, not a second source of truth.

  • INVALID_INVOCATION: the command line does not match the closed grammar; each documented option appears at most once and nothing else is accepted
  • INVALID_EVENT: the declared repository, ref, default-branch, or forge identity is unusable as written; pass a lowercase owner and name, full refs/heads/ references, and –forge for a host outside the known table, as the stderr reason line names
  • INVALID_PROFILE: the profile is not observe, enforce-introduced, or enforce; pass one of those three to –profile
  • REQUEST_UNREADABLE: the machine evaluation request bytes could not be read; nothing was evaluated, so resend the whole sealed request frame on stdin with nothing after it
  • CONFIGURATION_INVALID: a policy or control input violates its schema; correct the file the row names, since one unknown field or malformed value makes the whole file invalid rather than partly honored
  • DUPLICATE_JSON_KEY: a JSON input repeats an object key; drop the duplicate, since strict parsing refuses the file rather than choosing one of the values
  • INVALID_UTF8: a JSON input carries bytes that are not UTF-8
  • INVALID_JSON: an input that must be JSON does not parse as strict JSON; fix the syntax in the file the row names
  • UNKNOWN_SCHEMA: a JSON input declares a schema identifier this engine does not recognize; correct the identifier, or upgrade to a release that defines it
  • UNKNOWN_FIELD: a JSON input carries a field its closed schema does not define; unknown fields refuse rather than pass through unread
  • NONCANONICAL_ARRAY: a JSON input array violates its required canonical ordering or uniqueness; sort it and drop the repeated members
  • DIGEST_MISMATCH: a digest carried by an input does not match the bytes it names; the input is stale or altered
  • CONTROL_BINDING_MISMATCH: an external control is bound to a different repository, ref, or run identity than this evaluation; nothing is applied, so reissue the control against the identity under review or leave it out
  • EXCEPTION_OVERLAP: accepted exception items select the same finding more than once; narrow the debt and waiver items until each finding is selected once, since overlap is refused rather than double-suppressed
  • UNSUPPORTED_CAPABILITY: a candidate document declares a reserved amiss: capability this engine does not implement; correct the declaration in the named document, or upgrade to a release that implements it
  • GIT_REPOSITORY_UNAVAILABLE: the –repo path does not open as a Git repository of the declared object format; pass the checkout root from git rev-parse –show-toplevel and match –object-format to git rev-parse –show-object-format
  • GIT_OBJECT_MISSING: a commit, tree, or blob the run needs is absent from the object store; fetch full history or name commits the store holds, and for a whole-tree scan pass –base $(git rev-parse HEAD) –index rather than the all-zero or empty-tree id
  • GIT_OBJECT_WRONG_KIND: a Git object is not the kind its use requires, as when a named commit resolves to another type
  • GIT_OBJECT_UNREADABLE: a Git object exists but its bytes cannot be decoded; the object store is damaged, so check it with git fsck and restore it from a fresh clone
  • GIT_INDEX_INVALID: the staged index file does not parse under the index grammar; remove .git/index and run git reset to rebuild it, or compare two commits instead of the index
  • GIT_INDEX_OUTSIDE_REPOSITORY: GIT_INDEX_FILE names an index outside this repository’s own git directory, and the scan reads nothing there; unset it, or run from the repository whose Git process set it
  • GIT_INDEX_FORMAT_UNSUPPORTED: the index uses Git’s split or sparse format, which this reader does not expand; compare two commits instead, or turn the format off with git update-index –no-split-index, or git config index.sparse false and git sparse-checkout reapply
  • GIT_INDEX_UNMERGED: the index holds unmerged conflict entries, so no single staged state exists; finish or abort the merge before checking the index
  • GIT_INTENT_TO_ADD: the index holds an intent-to-add entry whose content is not staged; stage the file or drop the intent entry before checking the index
  • GIT_SNAPSHOT_CHANGED: the staged index changed while the run was reading it; rerun when the repository is quiet
  • UNREPRESENTABLE_PATH: a tree or index name is outside the path grammar, a backslash, a NUL, or a dot segment; the exact bytes are disclosed as hex
  • DOCUMENT_INVALID: a document’s bytes cannot be decoded as its format requires; save the file as UTF-8 or fix the syntax its grammar refused, and only that document is unsupported, never the run
  • PARSER_ERROR: the pinned parser failed on a document; this is an Amiss defect rather than a fault in the file, so report it as a bug with the named document
  • PARSER_PANIC: the pinned parser panicked on a document; the panic is caught and the run is incomplete, so report it as an Amiss bug with the named document
  • INVALID_SOURCE_SPAN: the parser returned a node whose byte span does not address the document; the parse is not trusted, so report it as an Amiss bug with the named document
  • RESOLUTION_ERROR: reference resolution failed internally; no input fixes this, so report it as an Amiss bug with the command line
  • RESOURCE_LIMIT_EXCEEDED: a named resource crossed its ceiling, and no option raises one, so the input has to come under it; the row carries the resource, the configured limit, and the observed lower bound
  • OUTPUT_LIMIT_EXCEEDED: the serialized report would cross the machine-json-bytes ceiling; no option raises it and findings are never dropped to fit, so the repository is past what one report holds
  • TOO_MANY_ERRORS: more distinct analysis errors accumulated than the retention ceiling; the lowest-keyed rows are kept and this sentinel stands for the rest, so fix those and rerun for what is left
  • REPORT_CONSTRUCTION_FAILED: the report could not be constructed or emitted; the run has no trustworthy output, so check that the output stream can still be written, then report it as an Amiss bug
  • SANDBOX_VIOLATION: the run breached its sandbox descriptor; the result is not trustworthy, so discard it and report an Amiss bug with the command line
  • TRUSTED_TIME_INVALID: a control that needs trusted time has no statement that verifies, absent or failing its binding; the run will not act on an unverified clock, so supply a statement bound to this run or drop the control
  • INTERNAL_ERROR: an engine invariant failed; this is a defect in Amiss, not in the input, so discard the run and report it as a bug with the command line
Last change: , commit: 3a3cfaf8

Running it in CI

The short form is the published GitHub convenience Action. It carries the engine inside the selected action tree, derives both commits from the triggering event, and turns findings into file feedback on the pull request. It is not the provider-authenticated controller lane:

name: docs
on:
  pull_request:
  merge_group:
  push:
    branches: [main]
permissions:
  contents: read
jobs:
  amiss:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
      - id: amiss
        uses: HardMax71/amiss@v0
        with:
          profile: observe
      - if: always()
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          name: amiss-report
          path: ${{ steps.amiss.outputs.report }}
          if-no-files-found: ignore

The published first run uses observe: introduced problems appear as Fixes without blocking, changed targets appear as Checks, and pre-existing problems stay as pre-existing inventory. An incomplete or untrusted run still fails. Triage the initial report, adopt any repository policy it needs, then drop the input to take the default, enforce-introduced, which blocks what a pull request introduces while the backlog stays warnings. On an unchanged head 52 of the 61 repositories in the corpus fail enforce, which blocks every failing finding, so that profile suits a repository whose backlog is already clear. When the site is built somewhere else the tree carries no generator configuration, so every destination is read against the source files until .amiss/router.yml names the router; What a documentation router serves has the names.

To make the check a merge gate, require the job’s status in the branch’s protection rule or ruleset; the job above reports as amiss. A repository that merges through a merge queue keeps the merge_group trigger, since a required check that never runs on the queue’s group leaves every entry waiting, and the Action derives the group’s base and head on its own.

What the Action does

Before running anything it verifies the selected binary against the release manifest shipped in the same tree. A wall-clock watchdog backstops the engine’s resource ceilings, and a scan that outlives the window is ended so the job fails with no result, never a verdict. The window defaults to 120 seconds, far above a real scan: this repository takes 0.26 seconds and a 1,620-document Docusaurus site 4.2 seconds. The job fails on exit classes 1 and 2, and under the default enforce-introduced profile only a finding the change introduced makes class 1. The outputs exit-class and report expose the verdict class and the JSON report path for anything downstream; the file sits in the runner’s temp directory, which is why the workflow above uploads it as the amiss-report artifact. A run that ends before the engine could scan, on an unsupported runner or a checkout missing a commit, reports exit class 2, and the path then names a file that was never written, which the upload step’s if-no-files-found: ignore tolerates.

InputDefaultRole
profileenforce-introducedobserve reports without blocking, enforce-introduced blocks what the change introduces and warns on the backlog, enforce blocks every failing finding
basederivedfull commit ID, overrides the event derivation
candidatederivedfull commit ID, overrides the event derivation
repo.repository root inside the workspace
object-formatsha1or sha256
annotationstruedisplayed Fixes and Checks, and scan errors, become file annotations
watchdog-seconds120wall-clock window before the scan is ended

When base and candidate stay empty, the event supplies them:

EventBaseCandidate
pull_requestthe candidate’s own first parentthe merge result
pull_request_targetthe payload’s base tipthe pull request’s head
merge_groupthe group’s base committhe group’s head
pushthe event’s beforethe pushed head

The first parent is deliberate: the payload’s base tip races the merge ref GitHub rebuilds lazily after a base branch moves, while the first parent is exactly the base the test merge was built from. Both commits must exist in the checkout. At the checkout default of depth 1 the Action deepens the checkout by one commit from its own head before it gives up, which reaches the merge commit’s parents and a single-commit push, so fetch-depth: 2 only saves a round trip; a push of several commits needs fetch-depth: 0, which the workflow above gives pushes. A pull_request_target checkout is the base branch, so the head is absent by design and the Action never fetches it: give actions/checkout ref: ${{ github.event.pull_request.head.sha }} with fetch-depth: 0 for that event.

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. Release assembly supplies the host the same way, to a manifest builder that stores an open build-source identity instead of assuming github.com; the release workflow is a checkable example of that input. The report and request formats are forge-neutral.

Pinning the Action

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. A vX.Y.Z source tag is an immutable exact pin whose dispatcher delegates to the equally immutable action/vX.Y.Z runtime tag; a source commit pins the dispatcher but still makes that second hop. Pin action/vX.Y.Z directly, or its generated Action commit, when policy requires the complete runtime tree in one ref.

pinsmajorv0moving major refsourcevX.Y.Zimmutable source tagmajor->sourcefollows thelatest releaseruntimeaction/vX.Y.Zimmutable runtime treesource->runtimedispatcherdelegates

Invoking the engine directly

The long form is useful outside GitHub Actions or when a workflow constructs the exact evaluation itself. Amiss’s own self-scan workflow builds the pull request’s engine, assembles a local action tree with its manifest, and runs that composite under --profile enforce. A minimal adjacent-commit direct invocation is:

- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
  with:
    fetch-depth: 2
    persist-credentials: false
- run: cargo install --locked --registry crates-io --version '=<reviewed-version>' amiss
- env:
    REPOSITORY: ${{ github.repository }}
    BRANCH: ${{ github.base_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 observe --format json > amiss-report.json

Replace <reviewed-version> with the exact release you reviewed. The leading = makes the Cargo requirement exact, Cargo checks the crate archive against the crates.io index checksum, and --locked refuses to recompute the packaged lockfile, so the command pins both the released crate and its dependency graph. The placeholder is deliberately release-independent. In a pull request --ref names the branch it merges into: the candidate is what that branch will hold after the merge, so a link pinned to it resolves against the candidate, and a link that breaks fails the pull request rather than the merge. Repository and branch names travel through environment variables because a branch can be named anything and text pasted into a shell script becomes code; the owner is lowercased in shell because GitHub hands it over with its registered capitals and Amiss refuses anything but lowercase. A scan is a pure function of the two snapshots and the invocation, so there is no baseline cache to warm between runs. As with the Action, graduate to --profile enforce once the first report is triaged.

The external rail extends any direct invocation into web evidence, advisory: derive the plan from the written report, probe its introduced destinations, and judge through the assessment. Amiss runs exactly this chain on its own pull requests, in the external-advisory job of the same workflow linked above, with every defect degrading into one summary line rather than a failed check:

- env:
    GH_TOKEN: ${{ github.token }}
  run: |
    gh release download v<reviewed-version> --repo HardMax71/amiss --pattern amiss-probe-linux-x86_64
    gh attestation verify amiss-probe-linux-x86_64 --repo HardMax71/amiss \
      --signer-workflow HardMax71/amiss/.github/workflows/release.yml
    chmod +x amiss-probe-linux-x86_64
- run: |
    amiss external-plan --report amiss-report.json --format json > amiss-plan.json
    ./amiss-probe-linux-x86_64 --plan amiss-plan.json > amiss-evidence.json
    amiss external-assess --plan amiss-plan.json --evidence amiss-evidence.json

The assessment refutes only what a probe or forge API positively disproved, so its rows are telemetry to read, not a gate to wire, until the rates have earned that. Its human summary also windows permanent-redirect retarget suggestions; temporary redirects remain evidence and never become edit suggestions. The external assessment has the recipe that probes every destination rather than the introduced ones. The prober ships beside the engine in every release, in the same SHA256SUMS and sigstore bundle, so the download above is the same Verified consumption recipe with the pattern changed. A release cut before the prober has no such asset; there the source build cargo build --locked --release -p amiss-probe stands in, which is also what the dogfood job runs so it probes with the pull request’s own prober.

That dogfood job uses GitHub’s cache only to replay successful, nonempty evidence when the same workflow run is retried. The exact generation key carries the cache schema and probe options, runner platform, immutable run and commit identities, and the attempt number. Restore fallback is confined to that prefix, so only an older generation from the same run and commit can match. The first attempt restores nothing, and empty evidence saves nothing. A restored file is still untrusted input: external-assess must accept its exact plan binding before the probe is skipped. A miss, cache outage, or invalid body runs the probe again; nonempty corrected evidence is saved as the current attempt’s generation for later retries. Every attempt derives the assessment locally. A new workflow run therefore never inherits an observation from the old one, and the cache never becomes a baseline or changes the advisory policy.

The SARIF projection turns the same run into GitHub code-scanning alerts on the lines the findings name. Two steps after any direct invocation, in a job whose permissions block adds security-events: write beside contents: read:

- run: amiss check <the check flags above> --format sarif > amiss.sarif
- uses: github/codeql-action/upload-sarif@24c7eb380a2dc368f2d129e4c65e51d172983a1e # v4
  with:
    sarif_file: amiss.sarif
    category: amiss

The category keeps Amiss’s alerts distinct from any other SARIF producer in the repository. The uploaded rows are ordinary code-scanning alerts, so GitHub’s remediation surfaces operate on them directly, agentic autofix included. What each result carries is stated in The report.

Code scanning reads less of the file than the file holds, in three ways. It renders no SARIF fixes, so the replacement a finding carries reaches amiss fix and other SARIF viewers but never the alert. It tracks an alert by the line hash upload-sarif computes and ignores every other fingerprint, so the finding key does not follow an alert across runs, and two findings of one kind on one line are one alert. And a pull request shows an alert only when its line is in the diff, while a drift finding usually sits in a document the change never touched. The Action’s annotations and job summary list those, so keep the Action or the human output beside the upload rather than in place of it.

On GitLab the whole job ships as a pinned template. GitLab’s CI/CD Catalog only serves components hosted on a GitLab instance, so a GitHub-hosted project publishes the honest equivalent: a template consumed as a remote include from a tagged URL.

include:
  - remote: https://raw.githubusercontent.com/HardMax71/amiss/v<reviewed-version>/integrations/gitlab/amiss.gitlab-ci.yml

variables:
  AMISS_VERSION: v<reviewed-version>

Both pins name the release you reviewed and move together, and the job’s image is pinned by digest. The template runs on merge-request pipelines and on the default branch, refuses to run with exit 2 until AMISS_VERSION is set, and verifies the downloaded binary against the release’s SHA256SUMS before executing it. It scans under AMISS_PROFILE (observe until the first report is triaged, the same ramp as everywhere else). A merge request compares its diff base with its head, a merged-results or merge-train pipeline compares its merge commit with that commit’s first parent, so drift already on the target is not charged to the merge request, and the default branch compares each push with the commit before it; a project’s first commit has none, so its checkout is read whole through --index. It declares the project’s identity from GitLab’s own variables, the server host and the lowercased project path with the gitlab dialect, the branch a merge request targets as --ref, and the default branch, so a URL into the project’s own files is checked like a relative link rather than left external. It renders Code Quality and JUnit from that same validated report without a second scan, and uploads three artifacts: the exact JSON report, a Code Quality report rendered in the merge-request widget and inline on the diff, and a JUnit report for the test widget. The fingerprint is the finding key, so the widget’s new-versus-resolved diff, taken against the default branch’s own report, follows the same identity the report uses. An exit-2 run writes no Code Quality rows, since the widget would read an empty file as a clean run; the JUnit report carries the error instead. This is rendering, not the trust lane: a blocking run still fails the job by exit class, and the provider-verified gate is the GitLab policy lane.

On Gitea and Forgejo the published Action runs unchanged, and it reads the Gitea URL dialect there, since both runners say which they are. Gitea Actions resolves uses: references through github.com by default, so the same two steps shown at the top of this page work in a .gitea/workflows/ file verbatim: verified on Gitea 1.24.7 with act_runner 0.6.1, where a broken reference failed the job with the engine’s exit class and its file annotation, and the repaired push went green. This is the convenience surface, not the Gitea and Forgejo provider lane, whose own floor is stated there.

Reading a run

When a run blocks, use the grouped feedback to orient, then read the exact JSON findings for repair evidence. The Action and human views show at most ten Fix and Check items combined, in engine order, then at most ten pre-existing items in a window of their own, each window with its own overflow line, so a run with many new Fixes still names the backlog item that blocks it. A displayed Fix or Check with a candidate text location becomes a file annotation at the citing line, a Fix as an error or warning by its disposition and a Check as a notice, so a changed target is seen in the diff without failing anything. Pre-existing inventory stays in the summary and report, and every control byte a path carries reaches the annotation escaped. If the scan failed, feedback is unavailable and at most ten retained errors are annotated instead. The blocking rows remain the report’s errors and findings whose effective_disposition is fail, and the complete grouped and raw sets always remain in the report. The Action’s report output names that JSON file, so a later step reads it in place without rerunning anything. One step lists every grouped PR item with its target and affected-place count:

- if: always()
  env:
    REPORT: ${{ steps.amiss.outputs.report }}
  run: |
    [ -s "$REPORT" ] || exit 0
    jq -r '.payload.feedback
      | select(.status == "available")
      | .items[]
      | [.action, .effective_disposition,
         ((.target | strings) // "-"), .location_count]
      | @tsv' "$REPORT"

What this surface is not

The Action invokes the public command: its branch is the candidate ref used for URL resolution, its report target ref is null, and it does not acquire provider-authenticated external controls, invoke the sealed bootstrap path, or publish through an independently authenticated integration. Caller-supplied identity fields never become provider authority. The authenticated lanes are separately operated source-built services: a GitHub App publishing an App-owned Check Run on the authoritative test merge, a GitLab pipeline execution policy job authenticated through OIDC, and a dedicated Gitea or Forgejo reviewer required by the effective branch rule. Provider-verified controls compares those lanes and links their setup, and Controller delivery documents the shared retry record; the GitHub lane’s own page is GitHub provider lane.

Before a commit exists

The same check runs on the staged index. The repository publishes a pre-commit hook that scans the staged state against HEAD with the amiss binary on the path (cargo install --locked amiss, at the version CI reviews). The hook runs on every commit, since a staged rename breaks links in files the commit never touched, and it runs under enforce-introduced unless told otherwise: what the commit introduces blocks and an older backlog only warns. It reads the index Git hands the hook, so git commit -a and git commit <path> are checked as they will commit. args picks the profile:

repos:
  - repo: https://github.com/HardMax71/amiss
    rev: v<reviewed-version>
    hooks:
      - id: amiss
        args: [--profile, enforce]

Replace v<reviewed-version> with the exact release you reviewed, the same convention as every version pin on this page. When the staged check reports fixes, amiss fix applies them to the working tree in place; restage and the same hook judges the repaired state.

Last change: , commit: 0987dade

Working with agents

Amiss meets coding agents in two directions: as the gate an agent runs into, and as a check the agent runs itself before pushing.

The failing gate

When a pull request fails, everything the agent needs travels with the failure. Annotations point to introduced Fixes. Grouped Checks and pre-existing inventory stay in the job summary and report, and the exact finding and error rows carry their fixed descriptions. Even a rejected invocation teaches: it prints the closed grammar on stderr, so an agent with no book at hand can construct a working command from the refusal alone.

Check before pushing

Tell your repository’s agents to scan before they push. The staged loop works from a linked worktree too, reading that worktree’s own index, so agents running in worktree isolation keep the same gate. If the repository keeps an AGENTS.md, paste this section into it. A repository that keeps a CLAUDE.md instead takes the same block there, or bridges the two the way this repository does, with a CLAUDE.md whose whole body is @AGENTS.md. That line is an import, and the check reads it as a reference: a @path line alone in a CLAUDE.md, CLAUDE.local.md or GEMINI.md names the file beside it that Claude Code or Gemini CLI loads, so renaming AGENTS.md breaks it visibly instead of leaving the agent without its instructions. Rule files in .cursor/rules are read once a policy include binds them, {"kind": "tree", "path": ".cursor/rules", "suffix": ".mdc", "adapter": "markdown"}. The block to paste:

## Documentation checks

This repository gates documentation drift with Amiss
(https://hardmax71.github.io/amiss/). After changing documentation, or code that
documentation points at, stage the change and check the staged state before committing:

    git add -A
    amiss check --repo . --object-format sha1 \
      --base "$(git rev-parse HEAD)" --index --profile enforce-introduced --format json

Exit 0 passes. Exit 1 blocks: the blocking rows are `errors[]` and the findings whose
`effective_disposition` is `fail`; each row's `description` says what it means and how
to fix it, and `location` names the exact source position. A finding with a `fix`
carries a proven edit, and `amiss fix` with the same arguments minus `--format` applies
every one; stage the result and check again. Exit 2 means the run itself could not be
trusted, and the error rows say why. If CI passes `--repository`, `--ref` and
`--default-branch-ref`, pass the same three here, or URLs into this repository's own
files go unchecked. Fix what the row points at; never weaken
`.amiss/scanner-policy.json` to silence a finding, and leave `.amiss/router.yml` to a
maintainer, since it states which router publishes the tree.

The block assumes the binary is installed, by cargo install --locked amiss or a release binary as in Quickstart; pin the version your CI pins.

Scheduled repair

GitHub’s agentic workflows run coding agents inside Actions and describe themselves as augmenting deterministic CI rather than replacing it. Amiss is the deterministic half of that pairing: it finds drift and refuses to guess, and an agent repairs what it found. A starting recipe lives at integrations/gh-aw/docs-drift-fix.md. Copied into .github/workflows/ and compiled with the gh aw extension, it runs the scan on a schedule and reads the report. What it can prove it repairs, and the pull request it opens passes back through the same gate it started from. Findings that carry a fix hand the agent the exact edit: the document, the byte span, and the replacement text, already proven against the grammar that will re-check them.

Claude Code

This repository doubles as a Claude Code plugin marketplace. One command registers it:

/plugin marketplace add HardMax71/amiss

Installing the amiss plugin from that marketplace adds a skill that knows the invocation grammar, the exit classes, and the fix loop; its text is maintained at integrations/claude. It also adds a hook the agent cannot skip: before any shell command that commits, the staged check runs under enforce-introduced, and a blocking or untrusted result stops the commit and hands the report back to the agent, which is how Claude Code’s hooks refuse a tool call. A repository with no commit yet, or a machine without amiss on its path, commits unchecked, and the hook says so.

Codex, Copilot and other skill readers

The skill is a plain Agent Skills directory: one SKILL.md whose frontmatter names the skill and says when to use it, so an agent that reads that layout loads it without the plugin. Codex looks in .agents/skills from the working directory up to the repository root, and Copilot reads .agents/skills, .github/skills and .claude/skills. Copy the directory into one of them from the release your CI pins:

mkdir -p .agents/skills/amiss
curl -fsSL -o .agents/skills/amiss/SKILL.md \
  "https://raw.githubusercontent.com/HardMax71/amiss/v<reviewed-version>/integrations/claude/skills/amiss/SKILL.md"

Replace <reviewed-version> with the release you reviewed, the same one the check runs. The commit hook stays Claude Code’s own, so under another agent the pre-commit hook in Running it in CI is the gate the agent cannot skip.

Last change: , commit: 8d6a1fe4

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.

The staged index is the one Git keeps for this process: index in the private git directory, or the file GIT_INDEX_FILE names when Git sets it, as it does for a hook under git commit -a or a partial commit. That file has to sit in the same private git directory; one anywhere else is refused with GIT_INDEX_OUTSIDE_REPOSITORY rather than read.

The working directory is never judged, but a staged check does look at it once, after the verdict. It names on stderr each document it read whose working copy holds other bytes than the staged blob, since a pass over stale staging says nothing about the edit still waiting to be added. A CRLF copy of a LF blob, which core.autocrlf and eol=crlf write and git status calls clean, is not a difference, and neither is a copy a sparse checkout left out.

What refs name

Branch refs describe identity and link scope; they never select either snapshot. The rolling request and report contracts carry candidate_ref, the candidate or source branch whose links are being evaluated, separately from target_ref, the protected branch to which branch-scoped controls bind. A direct branch update normally uses the same value for both, while a pull or merge request may use a feature branch as the candidate and the protected base branch as the target.

The default-branch ref is a third fact, used for URL resolution and never inferred to be the target. The public CLI exposes only its existing candidate --ref claim; the complete split is currently reachable only through the internal request and bootstrap surface.

The candidate identity

In staged-index mode the identity covers the complete logical stage-zero index, including skip-worktree entries. The digest chain has three steps: hash the sorted index projection, hash a synthetic snapshot over that projection, then bind the result into the staged candidate identity. A commit-pair run uses the corresponding commit candidate identity.

identityprojectionsorted indexprojectionsyntheticsyntheticsnapshotprojection->syntheticidentitycandidate identity+ repository, dialect,refs, basesynthetic->identity

Both refs are part of that identity preimage, alongside the repository, selected URL dialect, base, and candidate, so a trusted-time statement bound to one source and target relationship cannot be replayed for another. These JSON files are digest preimages, not accepted request documents, and the identity golden test validates each against its report-schema definition and reproduces the full chain through the production builders.

The snapshot request’s repository_handle: 3 is a stable protocol ordinal, not a claim that the operating system passed file descriptor 3. In the current safe-Rust subprocess path the bootstrap maps that logical handle to the fixed repository working directory before launch, and the engine opens only that directory. A future isolation backend may map the same ordinal to a different pre-opened mechanism without changing the request wire.

The provider identity

The provider controller uses a stricter orchestration identity containing the provider instance, repository and change, URL dialect, candidate, target and default-branch refs, object format, base and candidate commits, and both tree IDs. Each provider lane binds that identity from independently authenticated input, refreshes the change and protected merge gate through its own credential, and acquires authenticated SHA-1 commit wants through the same fixed-budget Git protocol-v2 path before launch. Bootstrap refuses unless the acquired roots reproduce the evaluation identity. The engine then reads and re-hashes their objects normally; fetching remains outside the engine. The provider’s Check Run, policy-job result, or dedicated review remains merge evidence rather than a third engine snapshot.

Reading Git directly

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.

Two rejections follow Git rather than the letter of its formats. An index written under index.skipHash, which feature.manyFiles turns on, carries an all-zero checksum that Git does not verify, and neither does this reader. A pack or pack index left without its partner by an interrupted fetch is skipped, as Git skips it. A split or a sparse index is a format this reader does not expand; it is refused with GIT_INDEX_FORMAT_UNSUPPORTED, whose text names how to turn it off.

The supported repository forms are the non-bare checkouts: a primary checkout with a real .git directory, a linked worktree, or a separate-git-dir checkout, the latter two through one bounded gitdir: indirection and at most one bounded commondir hop, with symlinked targets refused. A bare repository is refused directly, though its linked worktrees read through their commondir, and objects available only through Git alternates are not consulted. These are explicit boundaries of the direct repository reader, not empty snapshots or silently missing documents.

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, a base commit the store does not hold appears as:

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

The row also carries path, path_bytes, 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 holds its exact bytes as an array of byte values, 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.

Last change: , commit: 0987dade

Discovery

Discovery decides which files count as documents, and it is deliberately narrow. Files with the exact lowercase suffix .md or .markdown are structured-markdown; .mdx files are structured-mdx. Six exact extensionless basenames, README, CONTRIBUTING, CHANGELOG, SECURITY, SUPPORT, and CODE_OF_CONDUCT, are extensionless-markdown and use the Markdown adapter. .cursorrules and llms.txt are plain-advisory: they are scanned by an adapter that extracts no references, unless a policy include names one exactly and binds it to a grammar, which then reads it; a tree or suffix include keeps the built-in class. llms.txt is Markdown by its own proposal, so binding it to markdown checks its links. .adoc and .asciidoc are structured-asciidoc, and .rst is structured-rst. .txt stays off both lists: the suffix says nothing about what is inside. One file in the tree does say. A Sphinx conf.py names the suffixes that project reads, and Django’s names .txt, so a file carrying a declared suffix under the directory holding that conf.py is structured-rst and reads under the reStructuredText grammar. Nothing else moves: the same suffix anywhere outside that directory is a text file, because a suffix one site declares says nothing about a file beside it. What a documentation router serves holds the two declaration forms that are read and what is declined. A repository policy include still binds a suffix no configuration declares. .ipynb and .org are unparsed-markup: this engine has no parser for a notebook or for Org markup, so those files are discovered and counted as unsupported-document-format and their content is never read, the same honest count an unbound policy include reaches. Reports retain unparsed-markup on either snapshot side, including files under excluded trees. Notebook Markdown has measured reference yield, but its decoded cell spans are not physical JSON spans and the current report cannot name a cell; the notebook measurement records why that format remains visible but unparsed. Quarto’s .qmd suffix remains outside the document set, while MyST’s .md files receive only the CommonMark/GFM semantics their suffix promises. The adjacent-format measurement records why MyST, Quarto, and Org need renderer-aware adapters rather than suffix aliases. An include may instead bind one of the five built-in adapters, which reads the named path or tree under that grammar without installing another parser. One grammar answers per path per evaluation: the candidate policy’s bindings, or the base’s when the candidate carries none, and dropping a binding while keeping the include is policy weakening under policy/include-binding-removed. Every other file is a possible reference target, not a built-in document. These rows come directly from the classifier.

Nine directory names are always skipped, wherever they appear in a path. The list is the scanner’s own constant, checked in CI, and --explain-scope prints the same one:

node_modules  vendor  third_party  dist  build  .next  target  test  tests

The names are fixed: no configuration adds one or takes one away. A repository policy can still readmit coverage underneath a skipped name, because policy adds coverage and never removes it: a document include admits its one exact path, a plain tree include admits the whole subtree, and a suffixed tree include admits only its exact tail. That is the monorepo lever. A package legitimately named build, dist, test, or tests keeps its prose scanned through one tree include in the repository policy, and there is no mechanism in the other direction: Amiss always reads the whole repository, so a monorepo cannot scope a run down to one package. 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.

test and tests joined the list on measurement. Across fifteen public repositories, 391 of 3,934 discovered documents sat under one of those names and carried 400 references between them, about one apiece, while producing 45 of the missing rows, every one a deliberately broken fixture. Two were prose: the READMEs explaining pydantic’s own test suites. Prettier is the case that made it a defect rather than noise, since seven intentionally malformed MDX fixtures under tests/format/ refused its entire run; it scans now, and the first thing it reports is a real break in its contributing guide.

Eleven paths through the classifier:

docs/guide.md               structured-markdown   scanned
site/page.mdx               structured-mdx        scanned
README                      extensionless-markdown scanned
llms.txt                    plain-advisory        scanned, nothing extracted
docs/guide.adoc             structured-asciidoc   scanned
docs/guide.rst              structured-rst        scanned
docs/faq.txt                structured-rst        the docs/conf.py above it declares .txt
notes/plan.txt              not a document        no conf.py above it declares that suffix
notes/plan.org              unparsed-markup       counted, never read
vendor/lib/README.md        excluded              the vendor component is in the closed set
src/parser.rs               not a document        a reference target only

A file under a declaring root that is not prose is read all the same. Django’s docs/requirements.txt is 72 bytes of package names: it is discovered, parsed under the reStructuredText grammar, and extracts nothing, which costs the bytes it holds and reports no claim. That is also what Sphinx does with it, since Sphinx reads every file under its source directory carrying a source suffix and then says the file is in no toctree. Reading the declaration rather than the file is the whole rule: a suffix no conf.py names stays outside the document set however much prose it holds.

Markdown and MDX recognize frontmatter only at byte zero, optionally after one UTF-8 BOM. The first complete line must be exactly --- or +++; the closing line repeats it, except that --- also permits .... A recognized region is opaque to the document grammar and may contain at most 65,536 bytes, excluding the BOM. An opener without a permitted closer, or a closer past that bound, remains ordinary document text. The published frontmatter vectors execute this boundary, including LF, CRLF, bare CR, BOM, and exact-limit cases, through the production recognizer in the frontmatter test.

A reference definition whose decoded label begins with exact lowercase amiss: is a reserved governed claim. Entity and escape decoding happens before that test; case is not folded. Every reserved definition node contributes its exact source digest, including a losing normalized duplicate, and only the first normalized definition controls whether a consumer becomes an ordinary reference. A governed definition spelling the closed value grammar is evaluated as a claim, described in Claims. Everything else in the namespace on the candidate side is an unsupported capability boundary: the run ends incomplete with exit 2. A base-only definition does neither. The governed-definition vectors drive extraction, source hashing, candidate-only grouping, and report construction in the governed test, including one refusal vector per clause of the value grammar.

A file the engine tried to read and could not is unsupported too, and the run keeps going. Bytes that do not decode as the suffix promises, an HTML comment the MDX grammar rejects, a tab-indented list inside a JSX block that markdown-rs rejects though @mdx-js/mdx accepts it, a document past one of its ceilings: the document is counted, its row carries undecodable-document for bytes, unparsable-document for markup the parser refuses, or resource-ceiling-crossed, and every other document in the repository is still reported. A 45 KB link destination past raw-link-destination-bytes costs less, only that one reference, which is declined as oversized-destination. The two are apart because the fix is: one is an encoding, the other is the source, and <!-- --> in an MDX document is {/* */} written wrong. The exception is a page a Docusaurus site holds, since Docusaurus reads that comment by default where MDX alone refuses it. There the comment the grammar rejected is read as a comment, every other byte keeping its line and column, and a link written inside it is not read. Superset’s documentation is the case: 129 pages open with a licence header in one, and all of them are read. Two undecodable translations used to cost a 2,619-document site its whole report at exit 2, and that is the failure this class replaced. Nothing is skipped quietly: the human output names each such file, the unsupported-document-format finding records that its references went unchecked, and a repository policy protecting that path still fails the run through coverage-reduced.

Every count is reported: discovered, scanned, unsupported, excluded, unlinked. The last count is zero unless trusted complete site-build evidence supplies a source root, navigation manifest, rendered entrypoints, and the source documents reachable through the completed HTML link graph. unlinked-document then names a scanned structured document inside that root which is neither the manifest nor reachable. Outbound-reference counts never stand in for navigation evidence. The exact predicate is in the document finding evaluator.

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 object holding the raw bytes as an array of byte values, 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, 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.

Both commit-tree and staged-index discovery emit document rows strictly increasing and unique by those raw path bytes. That ordering is load-bearing: exact document queries and policy-inventory checks use binary search over it, and two-sided report construction merge-joins the ordered sides. The discovery ordering test pins the Git directory-boundary ordering against both snapshot modes, the report test pins interleaved base and candidate rows, and the amiss-scan pipeline benchmark tracks lookup and merge cost as the row count grows.

Last change: , commit: 544f1b2c

Resolution

Parsing turns each document into a list of occurrences: inline links and images, reference style links, autolinks, and any reference definition no reference in the document consumes, since an orphaned [api]: ./guide.md still maintains a destination someone will trust. In an agent instruction file, CLAUDE.md, CLAUDE.local.md or GEMINI.md, a line holding only @path to a named file is an import the agent loads, and it is read as a reference too. 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.

Markdown is read the way Python-Markdown nests a block under an opener line. The admonition, details and tabbed extensions put a body under !!! note, ??? tip or === "Tab" by indenting it four columns, which CommonMark reads as indented code once a blank line comes before it. That body is parsed as Markdown instead, so a link inside a MkDocs admonition is checked like any other, and the same lines inside a fence stay the example they show. Across the corpus only MkDocs trees write an opener with a body under it, so the reading is not gated on a mkdocs.yml.

A full or collapsed reference whose label no definition in the document declares, [text][gone] or [gone][] and their image forms, is read as well, since Markdown leaves it as the bracketed text it spells: it is a link that broke, and it resolves as label-not-declared. A pair glued to what surrounds it, a[i][j] or [A-Z][a-z]*, is an index or a pattern rather than a link. A page holding a template marker, {{ or {%, proves no label absent, because a generator’s template can write the definition Markdown then reads, and under a mkdocs.yml such a label is declined rather than missing, because mkdocs-autorefs answers it from the site’s own inventory.

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. JSX splits in two there, and the split is the MDX grammar’s own. A tag standing alone on its line opens a block, so what stands between it and its closing tag is Markdown of the page, already parsed into blocks: that body is read like any other, and only the tags on either side are opaque. A tag in the run of a line is one paragraph’s phrasing instead, and it stays opaque whole, attributes and body together. Either way the attributes and any expression among them stay unread, so an identity a component computes is still a blind spot. That holds for a lowercase <a href> or <img src> too. An MDX build hands those values to the browser as written, and Docusaurus turns a path into a bundled file only when Markdown syntax writes it, so a JSX path answers to the page URL rather than the tree. The one exception is a literal id or name on a lowercase element, read as the identity it declares, since reading one too many identities can only leave a broken anchor unreported. An HTML region still yields what a renderer would follow: <a href> and <img src> values resolve like any markdown destination, character references decoded into the semantic spelling, alongside the headings and id attributes the anchor tables already harvest. A tag spelled inside a comment or a script, style, textarea, or title body is followed by no renderer and is never mined, and the rest of the region stays the declared blind spot. Raw output injection is opaque in every dialect: AsciiDoc passthrough blocks and reStructuredText raw directives inject output the parser cannot read and count as opaque regions too. Markdown and MDX draw the line wider and treat every raw HTML region as opaque, comments included, while AsciiDoc and reStructuredText code blocks, literal blocks, and comments render as visible text or not at all and are never opaque. An AsciiDoc URL written directly is a link the way Asciidoctor reads it, with an attribute list, https://host/page[text], bare, or between angle brackets, a bare one ending before the punctuation that closes its sentence, and a mailto: only with an attribute list.

Each destination then passes through the generic resolver; trusted absolute forge spellings continue through the private dialect module. 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, and so is !file-loader!./asset.pdf, which is a bundler’s inline request rather than a path. A path beginning with / is a site route, not a repository-root shorthand. It stays unsupported unless sealed, candidate-bound site-build evidence maps that exact route and optional decoded anchor to a published source-backed or generated page, either directly or through a proved fragment-aware terminal redirect, unless it is a Sphinx :doc: target under a conf.py the tree holds, or unless a .amiss/router.yml above the document declares the URL path its own directory is served at and the tree holds the page the rest of the route names. Forge URLs need the complete identity group, not only the repository name. When the invocation provides --repository, --ref, and --default-branch-ref and selects a dialect, a URL on the declared host that names the same repository in that dialect’s spelling is converted to a path when it names the candidate branch or one full lowercase object ID in the run’s declared SHA-1 or SHA-256 format. Exact IDs on all five dialects resolve only through that commit and its objects already present under the declared Git roots. A completely walked local tree can prove a missing path; an unavailable commit, tree, or target object instead retains unsupported-version-scope with the exact commit and contained path, plus the decoded URL for the provider-evidence layer. The engine still fetches nothing. A named branch or tag outside the candidate remains version-scoped without guessed commit identity. Forge query semantics remain unsupported except for Bitbucket Cloud’s canonical fileviewer=file-view-default presentation choice and Bitbucket Data Center’s exact revision selectors. Transclusion-dependent historical absence remains unsupported because an object walk is not a historical site build. A URL outside the declared repository is external. It records the decoded destination so the layer that does fetch can read the list without walking the tree again, and it raises no finding, because there is nothing it decided.

Five 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 raw form reads the same way, and on github.com so does the content host, raw.githubusercontent.com/owner/name/ref/path, which serves files only; any of them may spell the branch as refs/heads/main. 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 its full lowercase object ID from the local object database and retains a known immutable scope when those objects are unavailable, and src/tag/ is always out of version scope because no tag is a trusted ref. The bitbucket-cloud dialect reads owner/name/src/commitish/path; Cloud places the commitish in one segment, so another branch or tag still retains a known path, while a candidate branch containing / cannot match that form. Its canonical default-viewer query is presentation only. The bitbucket-data-center dialect accepts an optional literal installation context with no projects or users segment, followed by projects/<key>/repos/<name>/browse/<path> or the corresponding users/<slug> personal route. No query means the declared default branch; at= must carry the exact candidate ref, another full branch or tag ref, or one full object ID. The history selector Atlassian documents as until=<oid>&untilPath=<path> is accepted only when the full ID has the run’s object format and the decoded path repeats the browse path. These boundaries follow Atlassian’s repository route, commit browsing, and history URL contracts. Line anchors follow the forge: #L10-L20 is a line reference on github and gitea, #L10-20 on gitlab, #lib.rs-10 on bitbucket-cloud when lib.rs is the target’s exact basename, and #10-20 on bitbucket-data-center. Relative references use the run’s declared dialect when one is present. 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. A branch spelled exactly like a full object ID is refused as ambiguous rather than assigned whichever interpretation happens to win on a forge.

One document, every destination shape:

[guide](guide.md)                     resolves beside this document
[guide](guide)                        resolves to guide.md, the spelling a router serves
[site](/docs/guide/)                  resolves only from matching sealed site-build evidence
[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 only for o/r, github, and --ref refs/heads/main
[lines](../src/lib.rs#L45-L48)         exact inclusive line selection under github or gitea
[web](https://example.com/manual)     external: recorded with its destination, never fetched
[wiki](https://ja.wikipedia.org/wiki/日本)   external too: judged as the URL a browser requests for it
[anchor](guide.md#setup)              resolves when a known renderer publishes that heading identity

The same decision, drawn:

resolvedestdestinationrelrelative pathdest->relrouteleading-slashsite routedest->routeforgeforge URL,same repositorydest->forgewith identity + dialectotherany other URLdest->othertreeresolve againstthe treerel->treeunsupunsupported-reference-semanticsroute->unsupscopecandidate refor exact commit IDforge->scopescope->treecandidate ref or local objectsversunsupported-version-scopescope->versother named ref or unavailable objectextexternal,recorded not fetchedother->exthittarget bytesand mode readtree->hitfoundmissexplicit-target-missingtree->missabsentdecltarget-declared-untrackedtree->declabsent, declared

A relative destination the tree does not hold is asked once more, under the spellings a documentation router serves: guide and guide.html for guide.md, a directory’s index for its README.md, and last the route a document publishes for itself, which is the id or slug its frontmatter declares in a tree that holds a docusaurus.config.*. The first spelling that names a file resolves the reference to that file, and the report names the file that answered while the occurrence keeps the destination the author wrote. A spelling reaches nothing that is not already in the tree, so it can widen what resolves and never invents a target; a promised directory and a same-repository forge URL are never re-spelled at all. A site generator whose configuration file is in the tree anchors a destination somewhere other than beside the document: an Antora resource ID at its family directory, a Docusaurus bare path or @site/ alias at the content root or the site directory, a Sphinx :doc: target with a leading slash at the directory holding conf.py, a raw HTML destination under a mkdocs.yml at the directory the page is published at, a Zola @/ destination at the content directory beside config.toml, a Jekyll {% link %} or {% post_url %} tag at the site’s source, a Hugo ref or relref beside the page and then under the language’s content directory, and an mdBook destination climbing past its book’s root under the src of the book that holds the page it names. Markdown never reads those templates as links, since each holds spaces, so they are read out of prose where they open, and a quoted URL through relative_url is the site route it names. Both generators fail the build on a template naming nothing, so such a target is missing rather than undecided, while one with no site of its generator above the page is unsupported-reference-semantics. The file’s presence selects the rule, and four of them are read further for what they bind: an Antora descriptor for its name, a conf.py for the suffixes it reads, a Hugo configuration for the content root and base a site route is answered under, and an Astro configuration for whether it loads Starlight, whose content root and base answer the same way. The nearest such file above the document selects it, and where none is above it and the tree holds exactly one, that one does, since a site in website/ reads pages that sit outside it. An alias in a tree holding several sites and none above the document is unsupported-reference-semantics rather than a directory of that name, since the value arrives when the site is built and this run cannot say which site would arrive.

Other generators decide a page’s URL in a configuration this engine never opens, and a relative destination is resolved against that URL rather than against the file. Under a Hugo configuration, a _config.yml, an eleventy.config.*, an .eleventy.js or an astro.config.* on the document’s ancestor chain, a destination the tree does not hold takes unsupported-reference-semantics with reason: unmodelled-route, the answer a leading-slash site route already takes, instead of being claimed missing. A destination ending in .md, .mdx or .markdown is the exception: a build reads those files and serves what it made under a name of its own, so the missing path stands. Under a book.toml the same answer covers a destination ending in .html that no book source reaches, because that names a page of the built site. Only the path side moves: a fragment on a document the tree holds is still read against the identities that document publishes, and a tree declaring none of these files reports every missing path it reported before.

A tree whose site is built somewhere else keeps no such configuration, so it names its own router in .amiss/router.yml instead. That turns on the spellings that reach files the tree already holds, and no others. The same file says where the directory is published, and a slash-rooted route opening with that base is read as a path under it. A route reaching no file keeps the boundary it had, so the base can resolve a destination and cannot claim one. The one exception runs the other way: when the base side served a route from a page the candidate no longer holds, the candidate reads that route under the base’s anchoring, so deleting the page is the missing target it is rather than a reference that seems to have left. The same holds under a generator whose build might supply a path, such as an Antora component an extension assembles: a page the base held in the tree was no build’s, so deleting it is missing rather than left to the build.

What a documentation router serves holds the spellings, the routers they were harvested from, the generator rules and what selects each, and what the union costs. What a repository declares is the section with the names you can write and four trees read with the file and without it.

A destination that opens {{ or {% never reaches the tree at all. The build fills it in, so it takes the same unsupported-reference-semantics an AsciiDoc {attribute} takes. The opening is enough, since an HTML attribute value ends at the next quote and hands over an expression with the closer cut off, and a file whose own name carries a single brace stays a path.

A destination no spelling reaches, in a tree that declares no such generator, is kind: missing with reason: path-not-found. The path that row names is the destination read from the document’s own directory wherever a rule kept that reading, so a finding never carries a directory a rule anchored at: a raw <img src="../../img/gone.png"> under a mkdocs.yml is missing at img/gone.png rather than under the directory the page is published at. The row also carries near: the one tracked path equal to the missed one apart from case, when exactly one exists and null otherwise. It answers the break a case-insensitive working copy hides, where Guide.md opens locally and resolves nowhere on the tree the forge and Linux CI read. A repository holding both spellings names a real ambiguity and stays bare. A lone reference whose written path part is the missed intent’s exact tail turns that neighbor into the finding’s fix, replacing only the bytes the author wrote while a fragment rides untouched beside them. A reference-style link’s path is written in its definition, so the fix edits the definition, and references sharing one definition share that one edit, which is the one case where a finding over several occurrences carries a fix.

When the missed path existed in the base tree and disappeared from the candidate, the same resolution also carries same_object_at if exactly one candidate-added entry has the identical Git mode and object ID and that identity belongs to exactly one removed path. Copies, duplicate content, mode changes, and edited moves leave it null. An index holds no directory entries, so under --index a removed directory pairs with the one added directory holding exactly its files, and only when no other removed directory held the same tree. When the author wrote a plain relative path that reached the missed file from beside its document, that one move also supplies the fix: the path rewritten, relative the same way, to where the change put the same bytes, with any fragment left as written. A site route, a generator’s anchoring, or a path that needs escaping keeps the fact without a fix, since the author’s own spelling cannot be carried over. The human form prints it after the reason as same bytes at and the path. The case-only near fact remains independent and answers first.

A destination no spelling reaches is asked one last question, against a declaration the repository already publishes for Git rather than for this engine. Only the tracked .gitignore files on the path’s own ancestor chain can name it, and the nearest one that answers travels with the finding. What comes back is target-declared-untracked, a record under both profiles, so the reference stays counted rather than cleared. The engine never asks whether a path is ignored; it asks whether a line’s own spelling bounds what that line can clear, since a line reaching further than it spells would answer for an unbounded number of references.

Two shapes qualify. The first is a literal that a slash anchors to the file’s own directory, at the front or in the middle, with no pattern or escape byte and no empty, ., or .. segment; it names one path, and a trailing slash makes it answer for that path’s descendants too. The second is the bare *, which says the directory holding the file keeps nothing, so it answers for every path under that directory. A negation is counter-evidence to a * standing beside it: !.gitignore keeps that one entry out of the declaration, while a negation this cannot bound, !*.md or anything carrying a slash, drops the emptied directory altogether.

Everything else is refused. A pattern with no slash matches wherever the tree happens to hold that name, at any depth, so one word in a root file could answer for a path anywhere in the repository; those references stay missing targets. Git applies no ignore rule to a file already tracked, and neither does this: a path the tree holds never reaches the question.

AsciiDoc destinations reach one rule of their own before anything else. A target still holding {name} cannot be a path, because the value arrives when the site is built and this engine reads two trees, so it is unsupported-reference-semantics rather than a guess at a directory called {name}. Across Quarkus that is roughly a quarter of every reference, so reporting them as missing would have buried the real breaks. The double-angle shorthand keeps an unambiguous document.adoc#anchor as an inter-document target rather than turning the entire value into a local ID. Inside an Antora component, a document under modules/<name>/ with antora.yml at the component root, an xref, a family-qualified include, or an image is read as the resource ID Antora reads it as and anchored at the family directory of its module, so xref:index.adoc[] in modules/api/nav.adoc names modules/api/pages/index.adoc. The component is every source root whose antora.yml spells the same name, and one reserving the ext block is assembled by an extension, so a resource it does not hold is undecided rather than absent. Outside Antora an image joins imagesdir, which is empty unless a document sets it, so a document that sets none reads its images beside itself, the way Asciidoctor and a forge’s preview both do, and one whose header sets a single literal directory reads them under it. A document another includes takes its includer’s value, and one that sets it anywhere past the header, twice, or through an attribute leaves its images undecided. A heading anchor on an AsciiDoc target resolves through the Asciidoctor rule in What thirteen renderers call a heading, the only rule whose identities all carry a prefix.

An AsciiDoc destination that needs the build’s own state is declined as attribute-dependent rather than guessed: one holding an unexpanded {attribute}, an image whose imagesdir the run cannot know, since it is an attribute too, and a cross reference to an extensionless name, which is a page identity a site catalogue answers. A link or an include is read as written, so link:LICENSE[] names that file, include::NOTICE[] is missing when the tree lacks it, and a climb out of the tree is a traversal, and an image at a URL is external.

An AsciiDoc document publishes three identities a cross reference can name. A block anchor, [#install], [[install]], [source#install] or [id=install] on a line of its own, and an inline anchor, [[remove-refs]], [[[bib]]] or anchor:remove-refs[] in the flow of a list item, a paragraph or a section title, are identities as written. A section title is the reference text a natural cross reference names, so <<API entrypoints>> reaches the section titled API entrypoints however far down the page it sits, which is the reverse lookup Asciidoctor runs when the target is no known ID. It runs that lookup only where the target carries a space or a capital, so a title carrying neither publishes nothing beyond the identity the renderer rules already give it, and an inline anchor is read only where its ID follows Asciidoctor’s own grammar, since that scan runs over prose rather than over a line that carries nothing else. A paragraph whose first line is indented is literal text, rendered as the characters it holds, so nothing inside it is a reference or an anchor at all: the s!Figure (\d+)!<<fig-$1>>!g in a shell command in Asciidoctor’s own migration page is not a cross reference to fig-$1. An indented line carrying a list marker is a list item, which Asciidoctor checks first.

A reStructuredText heading anchor resolves through the Docutils rule in What thirteen renderers call a heading, and the labels a document declares outright with .. _name: resolve as themselves. The Sphinx roles that name a document, a file or a label are modelled by name, which is why the grammar profile says docutils-rst-sphinx-refs: :doc:, :download:, :ref:, :numref: and :term:. A :download: names a file as written, beside its document, or under the directory holding conf.py when it opens with a slash, and so do the paths an image, figure, include or literalinclude names, since Sphinx reads each of them the same way. A :numref: is a label the way a :ref: is. A :term: names a term some glossary directive in the tree declares, compared without case, and a term nobody declares is missing unless conf.py mentions intersphinx anywhere, which can bring in another project’s glossary through an extension that never names it. An image a substitution definition names, .. |logo| image:: logo.png, is an image like any other, and the link an image or figure opens, its :target: option, is a destination of its own. A relative :doc: target resolves beside its document, read under each suffix its root reads and again as the author wrote it, so a docname carrying a dot of its own reaches the file that name takes the suffix of. One already spelled with a suffix keeps it, since that spelling was the adapter’s and the adapter runs before any root is known. A source-root-absolute one resolves under the directory holding conf.py when that file sits above the document in the tree, under each suffix that conf.py declares, and stays a declared site route when nothing names the Sphinx root. An entry of a toctree body is a docname the same way and resolves the same way, bare or as Title <docname>; its options, self, URLs, and glob patterns name no single document and are passed over. A :ref: resolves against the snapshot’s label table, built after discovery from every name a document whose profile reads roles declares and bounded by declared-labels-per-snapshot, and a :numref: too: a unique declaration resolves to its declaring document, a name nobody declares is a missing target, and a name declared twice is undecided rather than guessed between. Labels follow the Docutils simple-name rule, case-folded with whitespace runs collapsed, a phrase declaration may arrive backtick-quoted or sit inside a list item or grid-table cell, and an undeclared name carrying a colon is treated as another project’s inventory, declared unsupported rather than reported missing. So are genindex, modindex, py-modindex and search, which Sphinx declares itself for the index and search pages every build writes. The extensions a conf.py loads add two more: sphinx.ext.autosectionlabel declares every section title as a label, prefixed with the docname and a colon where autosectionlabel_prefix_document is true, and sphinx.ext.autodoc or sphinx.ext.autosummary pull labels out of Python docstrings no document holds, so under either one a name nothing here declares is declined as well. A prefixless name absent from the local table can resolve only through one unique label in complete, candidate-bound Intersphinx evidence supplied through the sealed trust boundary. Local declarations retain precedence; duplicate external labels stay unsupported, while absent, partial, stale, malformed, or mismatched evidence leaves the name missing. The engine never fetches an inventory. Every other role stays an open extension point, declared rather than read into.

A Sphinx project that writes its pages in Markdown spells the same two roles in MyST, and they are answered the same way. {doc}`quickstart` is the docname :doc: names, taking .md rather than .rst, and {ref}`install-step` is the label :ref: names, looked up in the same table, which a MyST document fills through (name)=, its attribute blocks, the :name: a directive carries, the terms of a {.glossary} list and the object a domain:type directive describes. {term}`environment` is answered the way :term: is. The directives that name a file, {image}, {figure}, {include} and {literalinclude}, are references the way their reStructuredText forms are, read from the opener of a backtick or colon fence, and so is {download}`data.csv`. The myst-link rule in What thirteen renderers call a heading points a plain link at that same table: [text](name) where the tree holds no such file, and [text](#name) where the document itself publishes no such identity, are looked up as labels before either is reported missing, so the tree keeps whatever answer it had and a name nobody declares stays the missing target it was. A source-root docname such as {doc}`/api` resolves under that root the same way, and a root that loads MyST reads .md beside the suffixes conf.py declares, since MyST adds it when it loads, so a :doc: and a {doc} each reach a page written in the other format. Every other role is counted and left alone: {py:class}`Widget`, {func}`echo` and {issue}`4211` name a domain inventory Sphinx builds while it runs or a link template conf.py holds, so each is recorded as unsupported semantics rather than resolved, guessed, or reported missing. A plain link can name the same object, [Explicit text](#mypackage.MyClass). Where a {py:class} directive in the tree describes that class the name is already in the table and the link reaches the page holding it, and where nothing in the tree describes it the link stays a missing target, since reading any dotted fragment as an inventory name would swallow every real anchor break whose slug carries a dot.

Nothing here is read without a Sphinx declaration over the document, and two things count as one. A conf.py above the file is the first. The second is a page under one that renders the file in place of an {include}, since Sphinx parses an included file as part of the page holding the directive. That is how a changelog at the repository root writes the labels its site resolves, while the same file in a tree nothing includes stays the prose it looks like. The second reading says which files Sphinx parses and nothing about what each publishes: the including page’s own identity set is unchanged, so a fragment into it is answered exactly as before. The count says how many references the run saw rather than how many it answered.

Heading evaluation expands the closed local include subset in source order. An AsciiDoc include::path[] or option-free, document-level reStructuredText include participates when its literal relative target was already scanned under the same adapter; each nested path is relative to the file that includes it, except under a conf.py, where Sphinx reads every nested path from the page and so does this expansion. The same holds for everything else an included file writes: a file a Sphinx page includes, directly or through other included files, reads each relative reference from that page’s directory rather than its own, so an image, a :doc:, a download or an include in it names the page’s file, and one the page lacks is missing there. A file several pages include has to reach its target from each of them. An include carrying options is left out, since MyST’s relative-docs and relative-images read paths from the included file instead. An Antora partial is read the same way for its cross references and images, from the module of each page that includes it, while its own includes stay with the partial, which is how an Antora build reads them. An include selecting a tag or lines leaves the partial read from its own module, since that include renders only part of it. An MDX partial joins the same subset: a default import of a relative Markdown document rendered as an element, which is how Docusaurus composes one page out of several files, and the identities flow to the page rather than back to the partial. So a fragment written inside a document Docusaurus publishes no page for is undecided rather than absent: the identity it names belongs to whichever page renders the file, and one partial may be rendered into several. What a documentation router serves holds the names a content path excludes and what stays a path there. So does a MkDocs snippet line, --8<-- "path", under a tree that declares MkDocs, resolved from the directory holding that declaration rather than from beside the document. Each include line is also a reference of its own, since both generators expand it before Markdown reads the page, in a fence as much as in prose: the snippet line under that declaration, and an mdBook {{#include}}, {{#rustdoc_include}} or {{#playground}} in a page of the book’s source directory, read beside the page. So a deleted or renamed listing is a missing target, and one that changes under unchanged prose is a check. A backslash before an mdBook command leaves it text.

A selector after the path is read too. A line range, listing.rs:2:10 or code.py:1:4, becomes the line fragment L2-L10, so the start must fall inside the file and those lines are what the reference depends on; the end stops at the end of the file, the way every include grammar stops it. A name is a marker the file has to carry, spelled the way its own generator reads it: an mdBook ANCHOR: name, a snippet’s --8<-- [start:name], an AsciiDoc tag::name[] for include::file[tag=name], and for literalinclude the text a :start-after:, :start-at:, :end-before: or :end-at: names, or a def or class of the last name a :pyobject: spells. A marker the file does not carry is selection-not-found, missing like a deleted file. Comprehensive Rust includes exercise.rs:magnitude and exercise.rs:normalize, and the file carries neither anchor. A generator instruction under the same declaration, ::: pydantic.config, is an edge this engine reads and cannot follow, because what it pulls in is built by a program rather than held by the tree, so the page keeps the identities it writes itself and absence in it stays undecided. Two more spellings under that declaration are read the same way: a heading whose text is an <!-- md:setting name --> comment a hook expands, and a === "Title" content tab the tabbed extension slugs under settings that live in mkdocs.yml. Neither identity is in the tree, so neither page proves absence. An option-free literalinclude contributes no parsed headings. Its selection is checked the way a line fragment is: :lines: 5-8 must fall inside the file and tracks those lines alone, a list or an open end reads as the span from its first selected line to its last, and a :pyobject: or a start or end text is a marker checked the way an include’s is. The range is spelled the way a run with no forge, or a GitHub or Gitea one, spells a line fragment, so under a GitLab or Bitbucket Data Center identity it goes unchecked. The graph is bounded by references-per-document, parser-nesting, and aggregate-heading-anchor-evaluation-bytes-per-snapshot. A cycle, an unscanned or non-local target, a build-time attribute, include options, or a nested parser context leaves the identities collected up to that edge partial: a published identity can still resolve, but absence stays undecided rather than becoming a guessed missing anchor. Expanded AsciiDoc remains partial even when every edge is available because its document-attribute and conditional state is not reproduced; reStructuredText can prove absence inside the closed option-free subset. The same holds in the other direction for a chapter: an AsciiDoc document another one includes renders inside the book that includes it, and so does one an Antora component keeps among its partials or examples, so a cross reference in it may name an identity another chapter declares. Absence there stays undecided too, while a document nothing includes still proves it.

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. A GitHub or GitLab URL promises no kind at all: both forges redirect a blob URL naming a directory to its tree and back, and serve either with a trailing slash. A destination that normalizes to nothing names the repository root: . in a root document, .. one directory down, or a forge URL with nothing after its ref. Every snapshot holds the root and no repository path spells it, so the link is declined as unsupported-reference-semantics with reason: repository-root rather than called invalid. Percent-encoding is decoded exactly once: %252F stays as the literal three characters %2F 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.

Fragments split by kind. Query strings are recorded as digests and acquire no semantics here. 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. A recognized numeric line fragment selects the inclusive raw lines. A range beyond the blob is resolution kind: missing with reason: line-fragment-out-of-range, reported as an explicit missing target. A valid range replaces the whole-file projection with only the selected bytes and file mode, so a change outside the range does not claim this occurrence’s dependency changed. Git LFS pointers and trees have no line selection and stay unsupported.

Every other fragment on a document target is a heading anchor, and a heading identity belongs to the renderer rather than to Markdown. Twelve rules are pinned, one per renderer or per configuration of one, and the resolver asks whether any of them would publish the anchor, counting the headings a document writes as raw HTML and the identities it declares outright, in raw HTML or in an attribute block, as well. A definition-list term is read beside them, because one renderer publishes an identity for a term on the counter its headings occupy. An anchor no rule publishes is kind: missing with reason: heading-anchor-not-found, an ordinary missing target; the row also carries near, the one published identity the fragment names apart from typography when exactly one exists and null otherwise. The fold covers the two spellings the pinned rules disagree on, case and the separator character, in two steps: case alone first, then case and the separator together, since the mdn rule publishes with _ what most rules publish with -. A duplicate suffix written _1 still folds together with -1 in the second step, and a lone reference over a verbatim-located fragment turns that neighbor into the finding’s fix. A fragment holding :~: carries a text directive, which a browser answers by finding text on the page, so it names no identity and is declined as an unsupported fragment. The union is deliberate: adding a rule can only grow what an anchor may match, and no repository policy narrows it. A document can add to it, by declaring an identity the way it would add a heading, which is an edit to the target that a reviewer reads rather than a setting that clears a finding. A footnote joins the union too, under the spellings six renderers publish for a note and its first call, fn:1 and fnref:1 among them. What thirteen renderers call a heading holds the rules, what each was checked against, and how far apart they are.

What the check will not do is judge on a parse that did not happen. A target that is not a parsing document class, an LFS pointer, a document the parser rejects, one the anchor budget cannot afford, or one no router publishes as a page keeps unsupported-reference-semantics, which now means exactly “not evaluated”. The projection stays the whole file: an anchor says where to look, not which bytes the reference depends on.

Version scope is equally narrow. The candidate is read, and a full immutable ID is read only from objects already present under the declared Git roots; unavailable objects are delegated for provider evidence instead of fetched. --default-branch-ref supplies a second trusted spelling so the resolver can split a ref from its path without guessing, and a URL naming the default branch while the candidate ref differs is still unsupported-version-scope. Site generators and language-aware tools still own route and symbol semantics. A complete site-build producer can contribute exact positive source-backed or generated route, anchor, and fragment-aware terminal-redirect facts for the candidate; absent, ambiguous, or stale mappings remain unsupported, while conflicting ownership and broken declared redirects become build defects retaining every available source. Guessing beyond that evidence would turn honest ignorance into a false pass. The resolver tests pin these distinctions.

Each resolved target is read from the object store and hashed, so the comparison knows the exact selected bytes and file mode on both sides. Numeric positions do not prove that those bytes still mean what the prose claims; they only make movement and byte drift observable. A symlink or submodule target, or a path beneath one, 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 committed pointer bytes are hashed. Those bytes include the declared OID, so an OID-text change is observable; a backing-store change that leaves the committed pointer unchanged is not.

Last change: , commit: 0987dade

What thirteen renderers call a heading

A heading anchor is not a property of Markdown. ## Setup & Config has no identity until something renders it, and the renderers disagree: github.com publishes setup--config, VitePress publishes setup-config, and Gitea publishes neither if the heading is empty after its filter. Checking guide.md#setup therefore means knowing whose rule applies, and guessing one would report live anchors as missing. One fragment needs no rule at all: the HTML standard scrolls #top, in any case, to the top of every page, so it resolves on any document whatever its headings. Starlight writes _top on the title of every page it builds, so on a page of an Astro project whose configuration imports @astrojs/starlight, #_top resolves the same way. github.com also lowercases a fragment before it looks for the heading, so #Setup--Config reaches setup--config there, while gitlab.com matches the fragment exactly, as a browser does on a built site. The resolver lowercases too when a run declares a GitHub repository with --repository, and matches exactly otherwise.

Resolution describes what the resolver does with that. This page retains what the rules are, where each came from, and what each was checked against, because a slugging rule that quietly stops matching its renderer looks exactly like one that still matches.

The rules

RuleServesDistinguishing behavior
githubgithub.com, GitLab, Docusaurus, Hugo’s github typekeeps letters, marks, numbers and connector punctuation; one separator per space; also anchors a heading written as raw HTML, on the counter its Markdown headings use
giteaGitea 1.27 repository files and wiki pagesdrops marks; publishes nothing for an empty identity; never suffixes a repeat
forgejoForgejo 16 repository files and wiki pagesGitea’s filter, but an empty identity becomes heading and repeats take -1
mdbookmdBook with smart punctuation offRust’s alphanumeric test, so Indic vowel signs survive where Gitea drops them
mdbook-smartmdBook as it shipsthe same, after -- becomes an en dash and ... an ellipsis
goldmarkgoldmark embedders keeping its own idsdrops every multi-byte rune; _ becomes a separator; empty becomes heading
python-markdownMkDocs with the default toc slugNFKD then ASCII fold, so Café is cafe and CJK is empty; repeats take _1
pymdownxMkDocs configured with pymdownx.slugs.slugifykeeps Unicode; one separator per space; repeats take _1
mdit-vueVitePress, VuePressa wide punctuation class collapses to one separator; a leading digit takes _
kramdownJekyll, GitHub Pagesstrips the leading run of non-letters; ASCII only; empty becomes section
docutilsDocutils and Sphinxevery non-alphanumeric run becomes one separator, so foo_bar is foo-bar; a leading digit run is stripped rather than prefixed; NFKD then ASCII fold, so Ⅻ chapter is xii-chapter
asciidoctorAsciidoctor and Antora, at the default idprefix and idseparatorthe only rule whose separator is _ and whose every identity carries a fixed prefix; spaces, hyphens, dots and underscores all become one _; keeps Unicode letters; (C), ..., arrows and -- vanish, so A -- B is _ab; repeats number from _2
mdnMDN Web Docs, built by rari, under a front-matter-config.jsondrops ASCII punctuation except !, -, . and _ and keeps everything else, Unicode included; a run of spaces becomes one _; empty becomes sect; repeats number from _2, and a definition-list term or raw HTML heading takes its number after every Markdown heading holds one

The AsciiDoc rule is the one pinned to a configuration rather than to a renderer’s only behaviour. idprefix and idseparator are document attributes, and this engine evaluates no attributes, so the rule holds their defaults and a document set that overrides either publishes identities the rule does not know. The title is read after Asciidoctor’s replacements, which turn (C), (R), (TM), an ellipsis, the four arrows and a dash between words into character references that the id rule then deletes; a spaced -- takes its spaces with it. A title whose every character is filtered away gets an empty id, which no link can name, so it publishes nothing here.

An anchor resolves when any of them would publish it, or when the document declares it outright. Adding a rule can only grow that set, so a rule missing from the table is the only way a live anchor is reported absent.

The union has a price in the other direction. A heading Setup & Config publishes setup--config on github.com and setup-config under VitePress, and a link spelled either way passes in a repository GitHub renders, where only the first works. So a repository that knows its renderer can say so, and the pin can only narrow the set. anchor_renderers in the scanner policy names rules from the table above, and a Markdown or MDX page is then read under those rules alone, beside the identities it declares. AsciiDoc and reStructuredText pages keep their own rules whatever the pin says, and a name the table does not hold refuses the policy rather than emptying the set. A narrower pin can only turn a resolved anchor into a missing one, which is why it fits a policy that only raises. Dropping the pin, or adding a name the base’s pin did not hold, is policy-weakened under policy/anchor-renderers-widened.

mdn is the one row read only in some trees: under front-matter-config.json or .front-matter-config.json, the front-matter schema mdn/content and mdn/translated-content keep at their root. Its underscore spelling is what most other rules write with a dash, so read everywhere it would answer for a broken link on any site. k8s-community’s #kinds_of_tests is one: github.com publishes kinds-of-tests, and the link is broken there.

Two of the rows are configurations rather than renderers. mdBook ships with smart punctuation on and MkDocs takes its slug function from mkdocs.yml, so both spellings are carried rather than one being chosen for the reader.

Footnotes

A footnote is no heading, but most renderers give its note and its first call an identity, and a page links #fn:1 to reach the note. Six rules carry those spellings, each checked against its renderer’s output. They key the identity three ways: by the label as the document writes it, by the label lowercased, or by the place the note’s first call takes among the notes called. So [^note], called first, is fn:note under Python-Markdown and fn:1 under goldmark.

RuleServesNoteFirst call
python-markdownMkDocsfn:<label>, also for a note nothing callsfnref:<label>
kramdownJekyll, GitHub Pagesfn:<label>fnref:<label>
goldmarkHugofn:<order>fnref:<order>
markdown-itVitePress, VuePressfn<order>fnref<order>
remarkDocusaurus, Starlightuser-content-fn-<lowercased label>user-content-fnref-<lowercased label>
mdbookmdBookfootnote-<label>fr-<label>-1

Only the first call is read. A second call to the same note publishes fnref2:note under Python-Markdown and fnref:note:1 under kramdown, and each renderer numbers repeats its own way. github.com is not in the table: it suffixes every footnote identity with a digest of the render, so no spelling a document writes can name one.

What a document declares

An identity can also be written down rather than left to a heading’s slug, and then it belongs to the author or to a construct no heading rule reads. Each of these spellings joins the union for every renderer, because accepting an identity a given renderer would not publish can only leave a finding unreported, never invent one. Seven of the rows write down no identity: they are the spellings a declared generator, hook, extension or layout owns, five saying the page’s identities are built elsewhere and two naming a reference. Eight rows are gated on a file in the tree, the way the route rules are; the rest are read wherever their profile is.

DeclarationSpellingRead inSelected by
html-idan id or name attribute on a raw HTML element, or on one written inside an mdx-code-block fencemarkdown, mdxany tree
attr-listan attribute block alone on a block’s first or last line, {#id}markdownany tree
attr-list-inlinean attribute block directly after an inline construct or a bracketed span, **text**{#id} or [text]{#id}markdownany tree
definition-terma term line above a : definition linemarkdownany tree
mkdocs-snippeta --8<-- line naming a quoted path, alone on the linemarkdownmkdocs.yml, mkdocs.yaml, zensical.toml
mkdocs-directivea ::: line naming what a generator renders, alone on the linemarkdownmkdocs.yml, mkdocs.yaml, zensical.toml
mkdocs-shortcodean HTML comment naming a hook’s shortcode, <!-- md:name -->markdownmkdocs.yml, mkdocs.yaml, zensical.toml
mkdocs-content-taba content tab opening a quoted title, === "Title"markdownmkdocs.yml, mkdocs.yaml, zensical.toml
hugo-shortcodea shortcode call alone on its line or anywhere in a heading, {{% name %}} or {{< name >}}markdownhugo.toml, hugo.yaml, hugo.json, config.toml, config.yaml, config.json, config/_default/hugo.toml, config/_default/hugo.yaml, config/_default/hugo.json, config/_default/config.toml, config/_default/config.yaml, config/_default/config.json
eleventy-templatea Liquid tag or output anywhere in a heading, {% name %} or {{ name }}markdowneleventy.config.ts, eleventy.config.js, eleventy.config.mjs, eleventy.config.cjs, .eleventy.js
myst-targeta target alone on its line, (name)=markdownany tree
myst-directive-namea directive’s :name: option, or a figure-md opener’s argumentmarkdown, rstany tree
myst-glossarya term of a definition list opening with {.glossary}markdownany tree
myst-domain-objecta domain:type directive’s object name, {py:class} widgets.Widgetmarkdownany tree
myst-rolea cross-reference role, {doc}`name`markdownconf.py
myst-linka plain link naming a label, [text](name) or [text](#name)markdownconf.py
mdx-commentan MDX comment ending a heading, {/* #id */}mdxany tree
mdx-heading-idan MDX expression ending a heading, {#id}mdxany tree
jsx-idan id attribute on a lowercase JSX elementmdxany tree
mdx-partiala default import of a relative document, rendered as an elementmdxany tree
mdn-interactive-examplean InteractiveExample macro call, which renders a Try it headingmarkdownany tree
asciidoc-anchora block anchor or attribute list alone on its line, [[id]], [#id], [source#id] or [id=name]asciidocany tree
asciidoc-inline-anchoran anchor in the flow of a line or a section title, [[id]], [[[bib]]] or anchor:id[]asciidocany tree
asciidoc-reference-texta section title, or the reference text an anchor or reftext gives, that a natural cross reference namesasciidocany tree
rst-targetan internal hyperlink target, .. _name:rstany tree

html-id is every id and name a raw HTML region carries, wherever in the document it sits. It reads an mdx-code-block fence too, because that fence is markup rather than code: Docusaurus strips the two fence lines out of the file before anything parses it, so the <details id="node-env"> written between them opens an element of the built page and the id on it is an identity of that page. Its loader unwraps the three- and four-backtick spellings with nothing but the word on the opener, so those are the ones read here and every other fence stays code. On the Docusaurus tree that spelling is thirteen of the missing-target findings, one link written into thirteen versions of the same page.

attr-list is the extension’s own block, in any of the spellings it accepts: {#id}, { id="id" }, { id=id }, among classes, and with kramdown’s leading colon. A block whose last line is nothing but an attribute block declares that identity for itself, which is how [](){#anchor-point} and a {#section} line under a paragraph work; an attribute block trailing other text on the same line declares nothing, and one inside a fence is code. The extension reads the block in the document’s own literal text, so a block inside inline code is code and declares nothing. A quoted value is one attribute however many spaces it holds, so ### with pip <small>recommended</small> { #with-pip data-toc-label="with pip" } still names with-pip.

The other end of the block is MyST’s attrs_block, which writes the identity of what follows on the line above it rather than on the last line of the block itself. {#paragraph-target} alone above a paragraph names that paragraph, and the identity is the same one the trailing form declares, so both ends are read. A directive opener leaves no blank line under it, so the opener and the block it holds share one paragraph and the attribute block is its second line rather than its first. {#mypara} under :::{note} still names the paragraph beneath it.

attr-list-inline is the extension’s other half, the block that attaches to the inline construct it directly follows rather than to the block around it. That is how * **\locale`**{ #mkdocs-locale }: the locale usednames a list item's own term while the sentence carries on after it, and the identity is the same one the block form declares. The block opens the text that follows the construct, because anything between the two breaks the pairing. A bracketed span is the other carrier,[text]{#id}, which MyST's attrs_inlineand Pandoc both write. A bracket pair naming no definition is plain text to the Markdown grammar, so no node is built for one and the]` is the construct’s own end: a block against one is read from the text it sits in wherever that text falls.

definition-term is the one row derived from the document’s text rather than written down by its author. Hugo publishes an identity for a definition-list term under autoDefinitionTermID, slugged by the rule its headings take and numbered on the counter its headings occupy, so a matchers term above a later ## Matchers publishes matchers and then matchers-1. The term is the line above a line opening with : , and a second definition under one term names no new term. Hugo’s own flag decides whether any of this is published and this engine does not read it, so a term joins the union wherever the spelling appears. A page that writes a definition list in a tree Hugo never builds gains an identity nothing publishes, which can leave a finding unreported and cannot invent one.

MDN writes its definition lists another way, - term over a nested - : definition, and the mdn rule reads those terms itself rather than through this row. rari names a term once every Markdown heading holds its identity, from the text of the element the term opens with, or from the whole term when it opens with text, so - `accept` (optional) publishes accept. A term opening with a cross-reference macro publishes what the macro displays, its second string argument or else its first, so {{cssxref("@layer")}} publishes layer.

mdn-interactive-example is the Try it heading MDN’s InteractiveExample macro renders above the example it embeds. Its identity is try_it, the English title under the mdn rule, and a call standing alone on its line declares it.

The two MDX rows are the same heading identity written two ways, because the attribute spelling is an expression in that grammar. mdx-heading-id is the classic ### Hello {#hello}, which Docusaurus escapes before MDX parses the file and then reads back out of the heading text; it is on by default and off under future.v4.mdx1CompatDisabledByDefault. mdx-comment is ### \noIndex` {/* #noIndex /}, the spelling that needs no escape. Either one ends the heading or it declares nothing, which is parseMarkdownHeadingId's own rule and the reason {/ #id */} after` names nothing, and either one is taken as written with its case intact.

jsx-id is the MDX answer to html-id, because MDX has no raw HTML and <details id="x"> in an MDX file is JSX. The split JSX itself makes is the one this rule uses: a tag name starting with a lowercase letter is an HTML element, so its id reaches the page and is read, and every other name is a component. A component’s rendered output is unknown here, so an id written on one is a prop rather than an identity. The blocks a component wraps are a different thing. The MDX grammar parsed them as Markdown of this page, so they are read like any other blocks, and a plain element written there publishes its id as it would anywhere else. What the component makes of those blocks stays unknown, which is why the option tables Docusaurus builds with <APITable> publish row identities this check cannot see, and why a link into one stays a finding.

mdx-partial is the one rule that reads another file. Docusaurus composes a page out of several documents by importing one and rendering it, import Tags from './_tags.mdx' and then <Tags />, and the headings that partial writes are headings of the page. The import resolves through the same path rules a link uses, only to a document the tree already holds, and only for a default import of a relative .md or .mdx file rendered as an element, so a package, an alias, or a stylesheet declares no edge at all. A partial that renders a partial expands too, under the include budget in Resolution, and two documents rendering each other leave the set past the cycle undecided rather than recursing.

The identities flow one way. The page publishes what the partial writes, because the partial’s headings are on the page. A link written inside a partial that names a heading of the page rendering it is a different question, and this check does not answer it: the same partial can be rendered into several pages, so the identity that link names belongs to a rendering context rather than to the file the link sits in. That is a known gap, and on the Docusaurus tree it is eight of the findings.

mkdocs-snippet does the same composition in Markdown, and it is the one row this table gates on a file. The syntax belongs to the pymdownx.snippets extension, so the line is read only when mkdocs.yml or mkdocs.yaml sits on the document’s ancestor chain, or the zensical.toml of Zensical, which builds the same sites with the same extensions, and on that chain alone: the rule moves the directory an include is read from, which the one site a tree declares cannot stand in for the way it does for the routes in Route spellings. Without one the line is ordinary text and includes nothing, which is what it is. The path is resolved from the directory holding that file rather than from beside the document, because that is where MkDocs runs, so a page whose whole body is --8<-- "CONTRIBUTING.md" publishes what the repository’s own contributing guide publishes. Only the single-line form with a quoted path is read; a path carrying a section coordinate names part of a file, which this engine cannot reproduce, so that edge is refused and absence in the page stays undecided.

mkdocs-directive is the line that pulls content in from outside the tree, and the one edge this engine reads and then stops at. ::: pydantic.config asks mkdocstrings for the documentation of a Python object and ::: mkdocs-click asks that plugin for a command tree, so the headings the built page carries come from a program rather than from a file. The page keeps everything it writes itself: an anchor naming one of its own headings still resolves, and an anchor naming none of them is declared unsupported instead of reported absent, which is the same answer a snippet the engine cannot follow gives. Only the form with whitespace after the marker is an instruction, so the :::note and bare ::: fences Docusaurus writes name no generator; a VitePress container such as ::: tip is the same shape, and it is read only in a tree that declares MkDocs, which a VitePress tree does not. That is the gate: without mkdocs.yml or mkdocs.yaml above the document, three colons are three colons.

mkdocs-shortcode is the page a hook rewrites before anything is rendered. Material for MkDocs ships one that replaces every <!-- md:name argument --> comment in the Markdown, so #### <!-- md:setting config.blog_dir --> reaches the built page as a heading whose identity is config.blog_dir. A comment carries no text under any rule in the table, so what this engine reads there is an empty heading, which is not what the page publishes. The page keeps every heading it writes itself and an anchor naming none of them is declared unsupported instead of reported absent, which is the answer the generator instruction gives. Without that declaration above the document a comment is a comment.

mkdocs-content-tab is the same answer for pymdownx.tabbed. A tab opens with === "Title", and the extension publishes an identity for the title under the slug function the site configures, combined with the heading above it where combine_header_slug asks for that. Both settings sit inside mkdocs.yml, whose presence is all this engine reads, so a page carrying a tab leaves its identity set incomplete rather than guessing which of the two spellings the build wrote. Three equals signs with nothing quoted after them are prose.

hugo-shortcode is the same answer for Hugo, where the call names a template rather than a file. {{% include "_common/store-methods.md" %}} reads like an include, but include there is a shortcode the site defines under layouts/, and it works by fetching a page and rendering it, so what arrives is a template’s output. The headings in that output are headings of the built page, and under autoDefinitionTermID so are the terms definition-term reads, which is why an anchor into either was reported absent before. A page that calls one now keeps every identity it writes itself and leaves the rest undecided. On the Hugo documentation tree that is all twenty-three of its missing-target findings, and no repository outside a Hugo tree moves by one.

Only a call standing alone as a block is read, since one written in the flow of a sentence renders inside that sentence and can open neither a heading nor a term. Both markers count, the {{% %}} form whose output is rendered as Markdown and the {{< >}} form whose output is raw HTML, because a heading can arrive through either. That tree does not separate the two readings: none of the eighteen pages whose only call is inline is anchored into at all, so the narrow rule is the smaller claim rather than the measured one. Without a Hugo configuration above the document a pair of braces is a pair of braces.

A call inside a heading is the exception to the block rule, because it writes part of that heading’s text, and a renderer slugs the identity from the text it was given. The Kubernetes website titles its closing sections ## {{% heading "whatsnext" %}}, which the site renders in each page’s own language, so no spelling in the tree names what that heading publishes. Reading it moves 38 of that site’s claims to undecided. Seventeen named the templated headings themselves and were wrong. The other 21 name headings a translation never wrote, so they are almost certainly broken, and they go undecided with the rest of the page, the same price the block rule pays. A heading that sets its own id with {#id} keeps that id and says nothing about the rest.

A Hugo page can hand itself to a template whole. The Kubernetes glossary is a page whose frontmatter names layout: glossary and whose body writes nothing, and the layout fills it with a term- anchor for every entry under the directory beside it. So a page naming a layout and publishing no heading or anchor of its own is read the way a page calling a shortcode is, and an anchor into it stays undecided. A page that names a layout and writes its own headings keeps them: a Kubernetes blog post names layout: blog, the layout wraps the post, and a link to a heading the post never wrote is still reported. eleventy-template is the same reading for Eleventy, which renders a Markdown page through Liquid before Markdown sees it: ## Passthrough File Copy {% addedin "0.2.14" %} and a ## {{ t }} a loop repeats are headings a template finishes, and all seven claims the rule moves on the Eleventy site were wrong. Liquid is read only under an Eleventy configuration and a shortcode only under a Hugo one, so a brace in a heading anywhere else is part of the text it slugs.

myst-target and myst-role are the two MyST spellings, which is how a Sphinx project writes its pages in Markdown. (name)= alone on its line is the target: the renderer writes that identity onto the block after it, and Sphinx keeps the same name as a label, so it also joins the label table a {ref} is answered from. The role is the reference, {doc}`quickstart` where reStructuredText writes :doc:`quickstart`, and Resolution says which roles are answered and which are counted and left alone. The target is read wherever the spelling appears, because an identity can only widen the set an anchor may match, while a role is read only under a conf.py, so a brace before a code span in an ordinary Markdown file stays the prose it is.

myst-directive-name is the name a directive carries. Every docutils directive takes a :name: option and MyST spells a directive as a brace-tagged fence, so :name: build-note under :::{note} publishes build-note the way (build-note)= above the block would. The option is read under a {name} tag and nowhere else, so the :::note fence Docusaurus writes and an ordinary code fence declare nothing. An eval-rst body is reStructuredText and its directives spell the same option, so a .. figure:: carrying :name: rst-fun-fish inside a Markdown page publishes that name too, and a reStructuredText document publishes it directly. figure-md takes the name as its argument instead, :::{figure-md} fig-target, since it is MyST’s own Markdown figure; every other directive writes a path or a title there, so the argument is read under that one tag and the domain tags the next row names.

myst-domain-object is the argument of the other kind of directive, the one that describes an object rather than formatting a block. Sphinx stores what {py:class}, {js:function} or any other domain:type opener names under that name as written, so [](#widgets.Widget) finds the class its own page describes. The colon in the tag is what marks one, which keeps {note} and {figure} out of the reading. What follows the name is the domain’s own signature grammar and this engine parses none of it, so a parameter list comes off the end and anything still carrying a space declares nothing. {py:function} open(name) publishes open, and {cpp:class} template<typename T> Holder publishes nothing at all.

myst-glossary is a definition list read the way Sphinx reads one. A list opening with a {.glossary} attribute block is a glossary, and what a glossary term publishes is the term itself, spaces and all, rather than a slug of it, which is how [](<#my other term>) finds it. One list can therefore publish two identities, the term as written here and the slug the definition-term row gives it, and a list with no {.glossary} above it publishes only the slug. Both of these rows are read wherever the spelling appears, since an identity can only widen the set an anchor may match, and both reach the label table a {ref} and a plain link are answered from only where a conf.py governs the page that writes them.

myst-link reads those same names from the other side, so it is a reference rather than a declaration. Sphinx keeps every name a page declares as a global label, and a plain Markdown link can name one where a path goes: [alert extension](syntax/alerts) in myst-parser’s own admonitions page reaches the (syntax/alerts)= target in a second file, and [colon_fence](#syntax/colon_fence) reaches one through a bare fragment. The tree answers first, so a destination naming a file still resolves as that file, and the label table is asked only once the tree has said no. A name no document declares keeps the missing target it had, since an undeclared cross reference is as broken as an absent path. The reading needs a conf.py above the document the way the role does, because a label is a global name and a path is not, so an ordinary Markdown repository is untouched.

The last rows belong to the other two profiles. asciidoc-anchor is the anchor an AsciiDoc author writes on a line of its own: [[id]], with the identity being everything before the first comma, or an attribute list naming one, through the #id shorthand after any style and before any .role or %option, [source#hello] or [#intro.lead], or through the named id attribute, [quote,id=qid]. asciidoc-inline-anchor is the anchor written in the flow of a line, on a list item, mid-paragraph or inside a section title: [[id]], the bibliography spelling [[[bib]]], and the anchor:id[] macro. Asciidoctor reads each as an anchor on the construct it sits in; an escaped \[[ and one inside an inline passthrough declare nothing. A section title that carries one publishes it beside the identity the asciidoctor rule generates for the title text with the anchor taken out, which is how == [[custom-sec]]Install Guide publishes both custom-sec and _install_guide. The title can also open with # characters instead of =, which Asciidoctor accepts from Markdown. A title reads the attributes the document’s own entries have defined by then, :product: Acme before == Using {product} publishing _using_acme, and {empty} or {sp} as themselves. A title naming an attribute the document does not define keeps the reference as written, the way Asciidoctor does, but the page then proves no identity absent, since Antora or the command line may define it. asciidoc-reference-text is the reference text a natural cross reference such as <<API entrypoints>> looks its target up under: a section’s own title, the text after an anchor’s comma, or a reftext attribute, each published where it carries a space or a capital. rst-target is the internal hyperlink target, .. _name:, which Docutils turns into an identity on whatever follows it. That name is also what a Sphinx :ref: looks up, and Resolution describes that lookup, which is a different question from whether a fragment names an identity.

A heading can also be written as raw HTML, which many projects do for a centered title. github.com anchors those, because its filter runs over the rendered document and sees <h1> and ## in one sequence: the text content of the element is slugged by the same rule, nested tags and comments contribute nothing, and a repeat of an earlier identity takes the next suffix. Forgejo does not, verified on its own README, where <h1 align="center">Welcome to Forgejo</h1> is the only heading rendered without an identity while all four ## headings carry one. The rules built from a Markdown tree, mdBook, goldmark, python-markdown, pymdownx, mdit-vue and kramdown, never see the element at all. So this is the github row’s behavior alone, and the union carries it.

What each rule was checked against

The published expectations are in heading-anchor vectors, which names the implementation behind every column. Twenty-four cases carry the divergences: punctuation runs, intraword underscores, precomposed and decomposed Latin, the Turkish dotted capital, CJK, a Bengali virama, an emoji variation selector, a Roman numeral, a no-break space, and a heading that filters to nothing.

Seven rules have a runnable implementation, and against those the table reproduces all 9,049 headings harvested from the ten repositories in The scan ledger with no mismatch: github-slugger 2.0.0 and comrak 0.54.0 for github, goldmark 1.8.4, python-markdown 3.10, pymdownx, @mdit-vue/shared, and kramdown’s own generator. The asciidoctor column is what Asciidoctor 2.1.0.alpha.0 generated for each case. The remaining four are transcribed and traced by hand: Gitea’s CleanValue, Forgejo’s prefixedIDs, mdBook’s id_from_content, and Docutils’ make_id. The mdn column came from rari’s own anchorize, compiled from its source at a86696d and run over every case. Against mdn/content it leaves 3 of the 2,581 anchor findings a release without it reported, and two of the three are real breaks. The published vectors name which of the thirteen is which and what each transcription is not checked against.

Eight documents, in corpus/third_party/anchor-fixtures/, carry what a renderer actually published for them, harvested 2026-07-26:

DocumentRendererIdentities
probe.md, this repository’s owngithub.com file view28
probe.mdmdbook 0.5.4, default configuration28
probe.mdpython-markdown 3.10 with toc and attr_list28
probe-html.md, this repository’s owngithub.com file view9
probe-attr.md, this repository’s ownpython-markdown 3.10 with toc, attr_list and fenced_code7
probe-mdx-heading.mdx, this repository’s own@docusaurus/utils 3.10.2, parseMarkdownHeadingId3
awesome-gitea.md, CC0gitea.com50
starship-ja.md, ISCstarship.rs, VitePress32

The github.com column comes from the file view, /repos/{owner}/{repo}/contents/{path} under the HTML media type, which is the renderer that publishes heading anchors. POST /markdown renders the same Markdown and publishes none, so a re-harvest through it would come back empty rather than disagreeing.

The Gitea pair is the only live evidence for that rule and the only place its missing duplicate suffix is visible: that one page publishes fifteen identities twice, so an anchor into it is ambiguous on Gitea and unique on Forgejo, for the same file.

probe-mdx-heading.mdx is the same question in MDX, answered by the function Docusaurus parses headings with under its mdx-comment syntax. Three of its seven headings declare an identity to that call and four do not: one whose comment is followed by text, one with no identity in the comment, one whose identity carries a space, and the plain {#id} spelling, which that call alone does not read. Its pair is compared as a subset, since the loader runs the classic spelling too and mdx-heading-id carries it. The identity a{b} is in the set because their expression allows it.

probe-attr.md is the declared identities: four heading spellings, an empty link carrying one, and a paragraph carrying one on its own last line. Five forms declare nothing, and they are pinned too: a block trailing text on the same line, one inside a fence, and three inside inline code, where the extension reads the syntax as the code it is. Its pair is compared as a subset rather than as a list, because these identities join the union beside every rule’s own.

probe-html.md is nine raw-HTML headings and one Markdown heading among them, which is where the wrapped element, the decoded reference, the stripped comment and the shared duplicate counter are pinned. Its <h2> written across three lines publishes --wrapped-title, the leading newline and two spaces intact, which is the kind of detail a transcribed rule gets wrong and a harvest does not.

How far apart the rules actually are

Over those 9,049 headings, github and comrak agree on every one, which is why GitLab reads the same identities as GitHub. gitea, forgejo and mdbook sit within 28 of them, and the 28 are combining marks, no-break spaces and connector punctuation. The site generators are the outliers: goldmark’s default differs on about 1,117, python-markdown on 1,431, and mdit-vue on 1,856.

Switching MkDocs to pymdownx.slugs.slugify moves 1,431 of the 9,049 and lands within 22 of github-slugger, which is the measured reason a MkDocs answer is a configuration rather than a renderer.

Renderer drift

Two of these implementations were rewritten inside twelve months. Gitea moved heading identities out of goldmark and into an HTML post-processor in January 2026, shipped in 1.27, which is where its missing duplicate suffix comes from. mdBook rewrote its generator in September 2025 under a new HTML pipeline. github-slugger’s character class exists only because of a 2021 commit to match GitHub on Unicode, and its one later change was a Unicode data bump made for the same reason.

That is what the fixtures are for. A re-harvest that disagrees fails a test instead of silently changing a verdict.

Translated trees

Translation mirrors are where anchor breaks concentrate in the wild. In the ledger’s survey, 103 of the 122 real anchor breaks sit in starship’s translated pages, every one with the same shape: the heading was translated, its slug moved with it, and the English fragment stayed behind in the links. A translated tree is a first-class scanned surface, since translated readers follow the same links, and nothing scopes it out of a run.

Two repairs hold up. Linking the translated heading’s own slug stays correct in each language under the renderer rules this page pins. Pinning an explicit raw-HTML id on the heading survives translation entirely, since the id is harvested as an anchor identity (Resolution) and does not move when the heading text does. Either way the check is the same one every other page gets: the fragment must name an identity the target actually publishes.

What is not modelled

Renderers outside the table publish identities this check will not match, and a repository served by one of them can see an anchor reported missing that its own site resolves. Pandoc, Hugo’s non-github id types, Sphinx and Docusaurus’s custom slug functions are the known cases. Within MDN, a heading whose text is a macro takes its identity from what the macro renders, which only the definition-list terms model, and a live sample’s frame, frame_ and the sample’s identity, is not read. Zola is a narrower case: it transliterates a heading to ASCII before slugging it, so Привет мир publishes privet-mir, and no rule here transliterates. A page under a Zola config.toml whose headings reach outside ASCII therefore keeps absence undecided: an identity the union knows still resolves, and one it does not is declined rather than reported missing. The fix for any of them is another row, derived and pinned the same way, since the union only grows and a pin can only name rows the table holds.

Last change: , commit: 9db51ff9

What a documentation router serves

[Plain Text](./plain-text) is dead in the tree and alive on the site. The file is plain-text.md; the router elides the extension, and starship’s own preset page links it both ways in one paragraph, once with the extension and once without. A checker that reads only the tree calls the second one broken. It is not broken, and 247 of the 516 missing references in the scan ledger’s rescan are that same shape: a target the tree holds under a spelling the router maps.

So the resolver asks the same question the router does. A destination the tree holds is its own answer. A destination the tree does not hold is looked up again under the spellings a modelled router serves, and the first one that names a file resolves the reference to that file. Resolution places this in the order; this page holds the spellings and where they came from.

The three spellings

SpellingA destination likeReachesServed by
extensionlessguideguide.md, guide.mdx, guide.markdownvitepress
output-extensionguide.htmlguide.mdmdbook, vitepress
readme-indexdir/index.md, dir/index.htmldir/README.mdmdbook, vitepress when configured for it

A spelling only ever names a file that is already in the tree, so it can widen what resolves and can never invent a target. Everything a spelling does not reach stays exactly as missing as it was, under the destination the author wrote.

The elided extension is tried under each suffix a page source carries, .md first, then .mdx, then .markdown. A router serving a page at a clean URL serves an MDX page there the same way, so reading guide as guide.md alone leaves [items](./item) missing in a tree whose page is item.mdx. Ionic’s documentation writes that link in seven pages and reaches item.mdx from every one, 24 references counted once per versioned copy. A destination already carrying one of those suffixes names a source file rather than a route, so page.mdx is never looked up as page.mdx.md.

Two spellings are never tried. A destination ending in / promised a directory, and the tree answers a directory itself. A same-repository forge URL is read by the forge, which serves the tree rather than a site, so blob/main/docs/guide stays missing even though docs/guide.md exists.

Where the spellings came from

The published expectations are in route-spelling vectors, harvested 2026-07-26. One probe tree of five pages, one destination per probe page so every verdict names its own case, and each router asked in its own voice rather than read from its documentation:

RouterVersion and configurationHow it answered
mdbook0.5.4, defaultthe href it emitted, resolved against the built output tree
vitepress1.6.4, cleanUrlsits own dead-link report, corroborated by the output tree
vitepress-readmethe same, plus the README-to-index rewrites starship configuresthe same
mkdocs1.6.1, default use_directory_urlsits unrecognized-link warnings

mkdocs serves none of the three. It demands the source path and warns otherwise, which is why a repository it publishes gains nothing here and loses nothing: ruff’s 102 missing references did not move by one. What it does move is the directory a destination is read from, and that is the directory-url rule below.

The vectors also keep a verdict this table does not model. mdbook rewrites a link to dir/README.md into dir/README.html while writing that page to dir/index.html, so its own source spelling is the single form it fails to serve. The tree holds that file, and a file the tree holds resolves without any rule being asked.

What a generator in the tree anchors

The three spellings hold in every tree, since guide reaching guide.md costs one 404 on a site with no router and nothing more. A second set holds only where the tree carries the generator’s own configuration file, because each of these moves a destination to another directory and would be a guess anywhere else. A rule turns on when one of its files is a blob on the document’s ancestor chain, nearest directory first. Presence is what selects a rule. Two of them then read one value out of the file that selected them, the Antora component name and the Sphinx source suffix, and the rest never open it.

Which sites a tree declares is a question about the whole tree, and which site owns a document is a question about that document. Where nothing on the chain declares a generator and the tree declares it in exactly one directory, that directory owns the document, because a site in website/ reading ../docs leaves every page it publishes off its own chain. A tree that declares the same generator in two places fixes no owner, so a document outside both keeps the chain’s answer. Five rules stay on the chain whatever the tree holds: astro, eleventy, hugo, jekyll and mdbook-pages answer for a page the build serves rather than for a file, so turning one on withholds an answer instead of finding one, and so does the partial rule further down. A configuration under a tree the scan already excludes declares nothing, which is how sphinx’s own repository carries 176 conf.py and declares one site.

RouterSelected byServes
antoraantora.ymlantora-resource
docusaurusdocusaurus.config.ts, docusaurus.config.mts, docusaurus.config.cts, docusaurus.config.js, docusaurus.config.mjs, docusaurus.config.cjssite-alias, content-root, document-id
mkdocsmkdocs.yml, mkdocs.yaml, zensical.tomldirectory-url
sphinxconf.pysource-root
mdbook-pagesbook.tomlbook-route, built-page
zolaconfig.tomlcontent-root
astroastro.config.ts, astro.config.mts, astro.config.js, astro.config.mjs, astro.config.cjsbuilt-route
nextjs-appnext.config.js, next.config.mjs, next.config.ts, next.config.cjs, next.config.mtspage-directory
eleventyeleventy.config.ts, eleventy.config.js, eleventy.config.mjs, eleventy.config.cjs, .eleventy.jsbuilt-route
hugohugo.toml, hugo.yaml, hugo.json, config.toml, config.yaml, config.json, config/_default/hugo.toml, config/_default/hugo.yaml, config/_default/hugo.json, config/_default/config.toml, config/_default/config.yaml, config/_default/config.jsonbuilt-route
jekyll_config.ymlbuilt-route

antora-resource reads an AsciiDoc destination as an Antora resource ID, [version@][component:][module:][family$]relative, in a document under modules/<name>/ of the component whose root holds antora.yml. The relative part is anchored at the family directory of the named module, or of the document’s own module when none is named. So xref:index.adoc[] in modules/api/nav.adoc is modules/api/pages/index.adoc, include::partial$success.adoc[] in a page of that module is modules/api/partials/success.adoc, xref:cli:index.adoc[] is modules/cli/pages/index.adoc, and image::diagram.png[] is modules/api/images/diagram.png. The families are page, partial, example, attachment and image. An xref defaults to the page family and an image to the image family. An include without a family coordinate stays relative to the file that includes it, which is Antora’s own compatibility rule for the plain include. This rule replaces the relative reading rather than following it: a page under pages/sub/ that writes xref:index.adoc[] means the family root, and Antora never looks beside the file. A version or component coordinate is answered from the source roots this tree holds for that component version: xref:1.0@guide.adoc[] under a descriptor saying version: '1.0' is the component’s own guide.adoc, and xref:other::page.adoc[] is a page of the ROOT module of a component declared elsewhere in the tree, in whatever version the tree holds, since a coordinate without a version means the latest one. A coordinate no root in the tree answers, another version or a component whose descriptor lives in another repository, names a catalogue this engine does not build, so it is unsupported-reference-semantics with the external-inventory reason rather than a path or a URL. A ./ or ../ relative is Antora’s own page-relative form and stays beside the document.

A component is not one directory. Antora assembles it from every source root whose antora.yml spells the same name, so a module coordinate is answered by whichever of those roots holds the resource. Spring Boot has four roots and all four declare name: boot, which is why xref:gradle-plugin:packaging-oci-image.adoc written under documentation/ means a page stored under build-plugin/. The name and the version are what is read out of the descriptor, each a plain scalar on a line of its own, and a root naming another component or version answers nothing for a coordinate that did not name it. The document’s own root is asked first, so a finding still names the path under the root the author wrote in.

The descriptor also says when the tree is not the whole component. The ext block is what Antora hands to the extensions that assemble a component while the site is built, and this engine runs none of them, so a component reserving that block holds resources no tree walk can enumerate. All four of Spring Boot’s descriptors reserve it, and its build zips generated pages and partials into modules/appendix/partials/configuration-properties, modules/api/partials/rest/actuator and eleven more directories before Antora reads any of them. A resource ID such a component does not answer is unsupported-reference-semantics with the unmodelled-route reason rather than a missing file. Where no descriptor reserves the block, as in Asciidoctor’s own documentation, every claim stands.

site-alias and content-root are the order Docusaurus’s resolveMarkdownLink tries directories in, read from packages/docusaurus-utils/src/markdownLinks.ts, for a document the site owns: one under the directory holding docusaurus.config.ts or its .mts, .cts, .js, .mjs and .cjs spellings, the list the site loader tries, and every document in a tree holding one such file and no other. @site/blog/img/output.png is blog/img/output.png under that directory, for a link and for an image. A bare .md or .mdx destination, one starting with neither ./, ../ nor /, is tried beside the document first, then under the plugin content path the document sits in, then under the site directory. On a translated page under i18n/<locale>/docusaurus-plugin-content-docs/ that content path is the locale’s copy and then the plugin’s own, which is how a page the locale has not translated is still reached. A .md or .mdx destination opening with / is tried under the same content paths and the site directory, never beside the document, rather than read as a URL, and an image opening with / reaches the file the static directory beside the configuration holds. Both hold for a page under one of the default content paths, since a plugin path or static directory a configuration adds is not read here; anywhere else they stay site routes. [static folder](static-assets.mdx) in docs/api/themes/configuration.mdx reaches docs/static-assets.mdx that way. The content paths read are the plugin defaults, docs, blog, src/pages and versioned_docs/<version>, under the site directory and, for a document outside it, under the directory holding the site, which is where a site in website/ finds the ../docs its configuration names. So [View](view.md) in react-native’s docs/legacy/direct-manipulation.md reaches docs/view.md, the file the archived copy of that same page under website/versioned_docs/ has always reached. A plugin configured to read another directory gets the site-directory step alone, and a localized tree under i18n/ is not read. A ./ or ../ destination is beside the document and nowhere else, which is Docusaurus’s rule too.

Where no docusaurus.config.* sits above the document and the tree holds several elsewhere, @site/ is a destination this run cannot answer rather than a missing directory called @site: the alias is expanded when the site is built, and with more than one site in the tree nothing here says which directory it names. That is unsupported-reference-semantics, the answer an AsciiDoc {attribute} already gets for the same reason. Only the opening is read, so a tree with a real @internal/ directory resolves that path as written.

document-id is the identity a document declares for itself. Docusaurus publishes a page at the id in its frontmatter rather than at its file name, so jest’s docs/CLI.md opens with id: cli, the site serves it at cli, and every link to it writes cli, which names no file in the tree. A slug overrides the id, read from the document’s own directory or, with a leading slash, from the content root it sits in, and a document declaring neither is published at its own path without the source extension, which is how a bare notes reaches notes.mdx. The routes are collected once per snapshot, from the documents it already holds, and a destination the tree does not answer is looked up in that collection after the three spellings, so a real file always wins. A route two documents claim is left out rather than decided between them.

This rule asks the document’s own ancestor chain nothing at all. A site names its content directories in the configuration file, which this engine never opens, and jest’s site sits in website/ while the documents it publishes sit in ../docs, so selecting on the chain would miss every one of them. Any docusaurus.config.* in the tree turns the collection on, and a tree with none has no collection at all. Inside a document the rule reads two frontmatter keys written on lines of their own; the region stays opaque to the grammar, and a value that is not a plain scalar is declined rather than guessed at. A page that declares a site-absolute slug also moves its own URL, and a destination relative to that URL is not read.

A document under one of those content paths is not always a page. Docusaurus excludes every name opening with _ from routing, a directory as well as a file, so docs/api/plugins/_partial-tags-file-api-ref-section.mdx in its own repository is served at no URL at all and is rendered into the two pages that import it, plugin-content-blog.mdx and plugin-content-docs.mdx.

RouterPublishes no page forUnder
docusaurusa name opening with _docs, blog, src/pages, versioned_docs/*

A fragment written inside such a document names an identity of the page that renders it, and one partial may be rendered into several, so the tree does not say which page that is. The identities the file writes itself still answer, and a fragment nothing in it publishes is undecided rather than absent, the same answer Resolution gives any target whose identity set is incomplete. That is the whole of it: a path in the same document is read from the file as written, since that is where Docusaurus reads one from wherever the page ends up, and a path the tree lacks is still missing. The three links in that one partial are 24 of the 70 missing targets a docusaurus clone reported, counted once per versioned copy, and none of them is broken: that site sets onBrokenAnchors: 'throw' for the default locale, so an anchor that really was absent would have failed its own build.

The name decides this rather than the import. An import says one document renders another, which a page published at its own URL may also be, and the walk that finds one is bounded by references-per-document and parser-nesting, so an edge nobody walked is not evidence that a file is a page. The exclusion is Docusaurus’s own, read from the name alone, and a tree that declares no site keeps every anchor claim it had.

directory-url reads a raw HTML destination in a document under a mkdocs.yml the way the browser does. mkdocs rewrites the destination of a Markdown link and leaves an <a href> or an <img src> written by hand alone, so that one is resolved against the URL the page is served at rather than against the source file. A page is published at a directory of its own name, or at its own directory when the source is that directory’s index.md or README.md. So <a href="getting-started/"> in docs/index.md reaches docs/getting-started.md, and <img src="../../img/light.png"> in docs/user-guide/choosing-your-theme.md, published at user-guide/choosing-your-theme/, reaches docs/img/light.png. A trailing slash names a page here rather than a tree, since every page URL ends in one, so the destination is asked without it and the three spellings answer. The document’s own directory is tried after the published one, so a raw destination that already reached a file beside the source still reaches it.

source-root is Sphinx’s :doc: role with a leading slash, in a document under the directory holding conf.py. :doc:`/testing` in docs/tutorial/deploy.rst is docs/testing.rst: the docname under the source directory, with a suffix that root reads. A docname is a file name without its suffix, so the dot in /releases/1.1 is part of the name and that target is docs/releases/1.1.rst, while a trailing slash is normalized away before the name is looked up, the way docname_join does it, so :doc:`</ref/applications/>` is docs/ref/applications.rst. A plain hyperlink with a leading slash is still a site route, since Sphinx emits it as written. A relative one naming a page’s .html under the same conf.py reaches the source Sphinx built that page from, under each suffix the root reads, since the build writes every page beside where its source sits. A :doc: target in a document with no conf.py above it is answered by the one source directory the tree declares, and stays the declared site route it was where the tree declares several.

Which suffixes that root reads come out of conf.py. A project that writes its pages in another suffix says so in source_suffix, and Django writes all 677 of its pages in .txt. Two forms are read, both on one line with the key opening it, which is where a Python assignment binds a name at the top level: source_suffix = ".txt" is one suffix, and source_suffix = {".txt": "restructuredtext"} is the mapping form, whose value has to name the reStructuredText parser, since {".md": "markdown"} says the opposite of what this rule wants. A commented-out line, an add_source_suffix call, a mapping left open across lines, and a list naming no parser are all declined, and a root that declares nothing this reader spells out plainly reads .rst as before. A root naming several is read under each, since Sphinx finds a docname’s file under whichever suffix it carries, and a root that loads myst_parser or myst_nb reads .md as well. So :doc:`/ref/models/querysets` in Django’s tree is docs/ref/models/querysets.txt, and the same declaration is what makes those files documents at all, which Discovery states.

A relative :doc: target and a toctree entry take the same suffixes. The adapter keeps the docname as the author wrote it, and resolution adds each suffix of the root the document sits under, or its own format’s suffix when no conf.py sits above it, so Django’s relative toctree entries reach its .txt pages.

Each of the spellings above widens what resolves and nothing else, like the three: an anchored destination is looked up in the tree and is missing when the tree does not hold it, so xref:load-templates.adoc[] in modules/api/pages/index.adoc is still missing while modules/api/pages/load-templates.adoc is not there. One thing does move: the intent. A routed guide keeps guide as the path the author meant, while an Antora xref’s intent is the family path and a Sphinx :doc: target’s is the docname under conf.py, because the author never meant a sibling file.

Where a rule keeps the reading from the document’s own directory, that reading is the intent and the finding names it. A Docusaurus bare path keeps the sibling, since Docusaurus tries the sibling first, and a raw <img src="../../img/light.png"> under mkdocs keeps img/light.png, the path a forge asks for, while the published directory still answers it with docs/img/light.png. The published directory is a URL rather than a place in the tree, so naming it in a finding puts a directory nobody wrote into the report: a destination under docs/en/docs/fastapi-people.md that reached nothing used to be reported as docs/en/docs/fastapi-people/{{ sponsor.url }}, and neither fastapi-people/ nor the rest of it is in that file.

One opening is not a path under any rule. A bundler’s inline request syntax reserves it for the loaders the request disables, so [assets](!file-loader!./asset.pdf) names a loader chain ending in a resource rather than a file. That is an invalid-reference and not a missing path with a loader name inside it. Only the opening is read, so a name carrying the character anywhere else is an ordinary path.

BundlerInline request opens with
webpack!, -!

The two forms webpack documents for its own loaders are both here: ! disables the configured normal loaders and -! the pre-loaders, and the !! that disables every loader opens with the first of them.

An expression the build fills in is not a path either. <a href="{{ sponsor.url }}"> in fastapi’s docs/en/docs/fastapi-people.md names a sponsor’s site once the page is generated and names nothing at all in the tree, so it is unsupported-reference-semantics with the attribute-dependent reason rather than a missing file called {{ sponsor.url }}. That is 39 of the 79 missing targets a fastapi clone reported.

Expression opens withand closes with
{{}}
{%%}

The opening is what decides, and the closer need not be there at all. A destination ends where the grammar holding it says, and that cuts an expression in half whenever the expression quotes something of its own. HTML ends an attribute value at the next quote, or at the first space when the value is unquoted, so <a href="{{< ref "#LifecycleHandler" >}}"> hands the resolver {{< ref and <a href={{ ref . "x" }}> hands it {{. Read as a relative path, each claims a file nobody wrote under a name nobody spelled. Kubernetes’ website tree writes the first shape 2,774 times.

A name carrying one brace is still an ordinary path and resolves as written, and so is one carrying a closer alone. Jinja, Liquid, Nunjucks and Handlebars all spell an expression this way, which is why the rows are the delimiters rather than the name of one generator. A tree that holds a real file whose name spells an expression, as a cookiecutter template does, gets this answer for it instead of the file, and so does one whose file opens an expression it never closes.

What the build answers instead of the tree

A generator that does not put its pages where its sources are breaks the assumption behind every rule above, that a relative destination names a place in the tree. Hugo, Jekyll, Eleventy and Astro each resolve one against the URL the page is served at, and that URL comes out of a configuration this engine does not open. Twelve ordinary projects put a number on it: of 1,596 missing targets, the 1,149 from repositories built on a generator with no rule here held 41 real breaks. So built-route answers such a destination with unsupported-reference-semantics and a reason saying the route model is not modelled, the way an absolute site route already does, instead of naming a file nobody wrote.

A page’s own source is out of that reach. A build reads foo.md and publishes what it made from it under a URL of its own, so no URL it serves is called foo.md, and no route model is needed to say the destination reaches nothing. built-route therefore reads the destination’s own suffix, the three that extensionless appends, and leaves the tree’s answer standing. It is the other end of the pair built-page reads: .html is what a build serves, and .md, .mdx and .markdown are what it consumed. An asset keeps the boundary, because its own prefix still moves: Jekyll’s docs write <img src="../../img/jekylllayoutconcept.png"> from a page served at /tutorials/convert-site-to-jekyll/, the climb lands on the site root rather than on the repository root, and docs/img/jekylllayoutconcept.png is there.

Sixteen destinations across a 56-repository corpus are that shape, out of the 1,523 the boundary covered, and they carry twelve findings. Eleven are breaks. Kubernetes’ website goes from 89 missing targets to 97: six translated READMEs link a code-of-conduct.md that only the repository root holds, and one Korean page copied two English links without their leading slash. Jekyll gains the docs/pages/team.md its docs/_docs/security.md writes, which that repository already reports from the copy of the same sentence under .github/. The eleventy site gains two siblings written with a directory too many, and the twelfth finding is a page of it showing <a href="my-template.md"> as an example of markup rather than as a link. hugoDocs, the rust book and spring-boot do not move, since no destination behind their boundary names a source.

The selection is the same as every rule above: the file has to sit on the document’s ancestor chain, and only the path side moves. An anchor into a document the tree holds is still read, because the identities a document publishes are enumerable whatever a site does with its URLs, which is why helix keeps all nine of its anchor findings and its one missing path. A tree carrying none of these files keeps every claim it had, which is how the Kubernetes community repository keeps all 254 of its missing targets.

mdBook is modelled rather than declared, because its URLs are in the tree. A book’s root is the directory holding book.toml, its pages are the Markdown under the source directory the [book] table names in src, src when it names none, and each page is served at its path under that directory, so second/src/ch01.md is second/ch01.html. A src climbing out of the repository, or one this reader cannot spell, leaves the book unread rather than read under the default. A destination climbing past the root is read back under the source directory of whichever book holds the page it names, and that is book-route: the rust book’s second-edition/src/ch09-02-recoverable-errors-with-result.md writes ../ch09-02-recoverable-errors-with-result.html, means the current edition’s copy, and 241 of that repository’s 403 missing targets are that one shape.

built-page is the rest of the same book. A destination ending in .html that no book source answers names a page of the built site rather than a file. The rust book’s redirects/ tree writes 79 of those and its chapters write 33 more, climbing to std, reference and nomicon on the same domain. None of them is a break and none is placeable, so all take the boundary, and what stays missing is what always was: 28 images under nostarch/ and two paths in a crate README.

Zola is modelled through the one prefix it spells. @/ opens a path from the content directory beside config.toml, so @/documentation/page.md in Zola’s own repository is docs/content/documentation/page.md, and 59 of its 80 missing targets resolve that way while the other 21 stay the dangling theme files they are.

A config.toml may be Zola’s or Hugo’s, and a config.yaml or config.json may be Hugo’s or any other tool’s, so the name says nothing and the file’s own bindings decide. Zola requires base_url and Hugo reads baseURL in any case, so a file binding the first is Zola’s and one binding the second is Hugo’s. Hugo lets baseURL go unset, so a file binding neither is Hugo’s too when it binds a camel-case key only Hugo reads, languageCode, defaultContentLanguage, contentDir, publishDir, enableRobotsTXT or enableGitInfo, where Zola writes snake case. A Rust workspace’s .cargo/config.toml binds neither, so it configures nothing and ripgrep, bat and helix keep every claim they had. Hugo’s rule withholds answers rather than adding them, so under a shared name it also needs the content directory the file names, content where it names none, to sit beside it, the way the Zola reading needs its own. A file under config/_default is Hugo’s by where it sits, and it declares the project two directories above it.

Those older spellings are common. Of eight Hugo sites checked on 2026-09-22 only three used hugo.toml or hugo.yaml: etcd.io and grpc.io configure in config.yaml, the CNCF glossary and containerd.io in config.toml, and opentelemetry.io in config/_default. Read as the Hugo sites they are, those five go from 3,373 claims to 21, most of them relative links that work because Hugo serves every page at a directory of its own, and etcd’s 945 fall to none.

Jekyll’s permalink template is what this leaves undone. docs/_config.yml in Jekyll’s own repository sets permalink: "/:collection/:path/", every page URL then ends in a slash, and a ../ climbs one level less than the file path does, which is 19 of that repository’s 24 missing targets. Reading the template means opening the configuration file, and no rule here opens one, so the boundary answers the whole docs/ tree instead and those 19 go with it. Three of the four real breaks sit in .github/, outside the tree that file declares, and the fourth is the page source docs/_docs/security.md names.

What a build’s own configuration says

A declaration says two things: which directory holds the pages, and what path the site is served at. A Hugo configuration already says both, so a repository keeping one is not asked to write them again. contentDir names the directory, read against the directory the configuration sits in, and the path of baseURL is what every route opens with. Where the file names no directory, Hugo reads content and so does this.

A language table names a root of its own. [languages.fr] puts its pages under the contentDir it binds and serves them under fr, below whatever the site is rooted at, unless it is the language the file names as its default and that default is not itself in a subdirectory. So a route without a code is the default language’s, and one with a code is that language’s.

Hugo serves every page URL in lowercase unless the configuration binds disablePathToLower true, so a route is read that way under a Hugo root: /functions/cast/tofloat/ reaches functions/cast/ToFloat.md. A section’s route reaches the _index.md that writes it and a bundle’s the index.md. Read whole on 2026-09-25 under the observe profile, Hugo’s own documentation resolves 3,762 references where it resolved 3,209 and declines 38 where it declined 591. The Kubernetes website resolves 27,630 where it resolved 27,063, and six claims arrive, each an anchor on its concepts index page, which publishes no heading of that name.

An Astro configuration that imports @astrojs/starlight says both as well. Starlight’s loader reads the pages under src/content/docs beside the configuration, and the quoted base the file binds is what every route opens with, the site root where it binds none. Astro slugs every path in lowercase, so a route is read that way here too. A page is served at the slug its frontmatter declares in place of its own path, read from that directory, and a directory’s index page is served at the directory. Starlight serves a page nowhere else, so a route no page claims stays with the build, which is where its fallback serves the default locale’s page for a translation that lacks one. A srcDir moving the sources, or a base spelled as anything but a quoted literal, leaves the site unread. Read whole on 2026-09-25 under the observe profile, Astro’s documentation resolves 12,106 references where it resolved 3,471 and Starlight’s own resolves 2,078 where it resolved 656. Three claims arrive, each an anchor into a translated page naming a heading that page does not carry.

A configuration that mounts its content rather than naming a directory says the same thing another way, and the mount targeting the content directory is read where no key names one. Several such mounts are a site composed of parts this does not take apart, so none of them names a root then. Hugo’s own documentation is the case: one mount reads its pages from content/en, and reading it takes that repository from 1,421 declined routes to 553.

A root belongs to its project rather than to the pages beneath it, since one page of a site routes to another wherever either sits. That is what lets a Japanese page on the Kubernetes website reach the English page its route names. Read whole on 2026-09-21 under the observe profile, its site routes fall from 28,075 to 4,272 and resolved references rise from 5,073 to 27,063, with no missing path arriving. Sixty claims arrive, every one an anchor on a page that only resolves now. Most name a heading the page does not carry, and the rest divide between headings written as a shortcode and translated pages whose prose kept the English anchor while the heading above it was translated, which are breaks a translation workflow tolerates.

A comparison reads this configuration the way it reads a declaration, with one difference. A site the candidate newly configures is lent to the base, so the commit that first binds baseURL is not charged with the anchors the binding makes readable: those links were broken before it and stay pre-existing. A site the base already configures keeps its own configuration on each side. Pointing contentDir at another directory changes what every route reaches, so a link that resolved before and misses now is introduced by the commit that moved it. A declaration can be read under the candidate’s alone because dropping or replacing one is reported as a control finding of its own; a configuration file has no such report, so only what the candidate adds is shared.

What a repository declares about its own build

Every rule so far is selected by a file in the tree, so a repository that keeps no configuration gets no rule at all. That is the common case for documents whose site is built somewhere else. Of the 54 repositories behind this page, 16 hold no generator configuration at all, 4,133 scanned documents between them. Some are plain trees that a forge renders, and they want nothing here. The rest are content a generator reads from another place: Grafana’s Hugo configuration is in a Docker image and a sibling repository, and Babel’s site is built from babel/website.

So the repository says it, since nothing in such a tree can. .amiss/router.yml names one router on a line of its own, router: directory-pages, and the directory holding that file is the root the rule anchors at, where the configuration file would have been. The nearest declaration above the document answers. The table below is every name the line accepts.

Declared routerTurns on
mdbooknothing
vitepressnothing
vitepress-readmenothing
antoranothing
docusaurussite-alias, content-root, document-id
mkdocsdirectory-url
sphinxsource-root
mdbook-pagesnothing
zolacontent-root
astronothing
nextjs-apppage-directory
eleventynothing
hugonothing
directory-pagespage-url
jekyllnothing

The file holds the declaration and nothing else. Comments, blank lines, a --- marker, CRLF endings and a byte-order mark are YAML’s own and read as nothing. Any other line refuses the run as CONFIGURATION_INVALID at the file: a name outside the table, a name in the wrong case, a base without its leading slash or with no router line beside it, a repeated key, or a key this page does not define. A typo therefore never passes as a declaration that does nothing. Only the candidate’s files are held to this, so the commit that repairs one completes.

A declaration turns on the spellings that resolve a destination and no others. built-route and built-page are not in that table and cannot be reached from a file a repository writes, because those two withhold an answer rather than serve a file: a repository that could reach them could clear its own findings with one line. What a declaration does reach is bounded the way every spelling is. It can move a destination onto a file the tree already holds, and it cannot clear a destination the tree lacks, so declaring hugo or jekyll or astro turns on no spelling at all. There is still no ignore file and no way to silence a finding. The base key below is the other half of the file and is read whichever router the line above it names.

nextjs-app is the Next.js app router, which serves app/docs/guide/page.mdx (or page.md) at /docs/guide, with no trailing slash. page-directory reaches that file from the route it is served at, so guide is guide/page.mdx. A link in such a page is read the way a browser reads it, from the page’s URL, which is the directory holding the page with no trailing slash, so ../other in app/docs/guide/page.mdx is app/other/page.mdx rather than app/docs/other. A route names its page and not its directory, so a route directory holding no page is missing, which is the 404 the site serves, while a destination whose last segment carries an extension stays the file it names. An image stays beside the file, because the MDX loader turns it into a module import. nextra’s own documentation had five links reported missing that its site serves, all read one directory too deep, and a site setting trailingSlash or grouping routes in (group) directories is not modelled.

page-url is the row a declaration exists for. A site of that shape publishes a page at a directory of its own name and rewrites no destination, so a relative destination resolves against the page URL, one level deeper than the source file’s directory, and a Markdown link is read that way as well as an <a href>. A page bundle is published at the directory holding it, so _index.md and index.md are the sources a destination naming that directory reaches. The reading from the document’s own directory comes first, so a destination that already reached a file beside the source still reaches that file and still fixes the intent a finding names. The page URL is a candidate added, never one replacing another.

The row is named for the shape because no one generator owns it. Hugo builds it by default, Jekyll’s permalink: pretty and its /:collection/:path/ template produce it, and Eleventy and Astro build it unless asked for a file. It was called hugo-pages in the release before this one, so a Jekyll repository had to declare Hugo to describe its own site. A name for each generator would be one reading under four spellings, every one a row to keep and a row to test. mkdocs publishes this shape too and keeps its own row, since it rewrites a Markdown link back to the source path and leaves raw HTML alone, so only raw HTML is read against the page URL there.

This is the one row a configuration file does not select, and Hugo’s own documentation is why. hugo.toml says Hugo builds the tree and nothing about the URLs it serves: uglyURLs moves every page, a permalink template moves it again, and a render-link.html render hook rewrites the destination before any of that, which is what hugoDocs does to its own [glob pattern](g). Jekyll’s _config.yml carries the same question in its permalink line, and neither answer is in the tree, so a tree carrying either file keeps the built-route boundary it had. The declaration is the repository’s own word that its pages are published at a directory of their own name with nothing rewritten, which is a claim only a person can make.

Grafana is the case it was built against. docs/sources/setup-grafana/set-up-grafana-live.md writes [ha_engine_address](../configure-grafana/#ha_engine_address), and the page it reaches is docs/sources/setup-grafana/configure-grafana/_index.md, one directory deeper than the source-relative reading, which is why 544 missing targets were reported for a tree whose maintainers had not broken 544 links. With router: directory-pages under docs/sources that count is 278. The 266 claims that went are one shape. A separate reading of the page URL was run over the references that resolve where they stood: 270 of 271 match it, and the one left over reached a file beside its own source once the trailing slash stopped naming a tree. Nothing moved into the undecided class, which stayed at 1,153 rows, and every one of the 52 anchor claims still reports. Another thirty-one joined them, since a path that resolves has its fragment read: ../../datasources/tempo/#span-filters reaches the Tempo page, which publishes no such heading now that span filters are a page of their own.

A page that moved leaves the URL it was served at behind, and that block is read under the same declaration. Hugo lists those URLs under aliases in front matter, serves a redirect at each one, and reads a relative entry against the directory the page’s own URL sits in, one level above the route the page is published at. Grafana’s own blocks show it: 784 of its entries carry the URL they produce in a trailing comment, 735 of those match that reading, and 6 match the page URL itself. Hugo’s documentation says the same, giving old-name and ../old/path on content/examples/example-1.en.md as /en/examples/old-name/ and /en/old/path/.

The grammar is a key opening a line with no value of its own, then the lines under it opening with a dash and a space, and the first line shaped any other way closes the block. A flow sequence, a plain scalar, and an entry opening with a slash are declined rather than guessed at, so this stays a reader of one block and not a reader of YAML. A URL two pages claim is left out the way a route two pages publish is, and the page that answers is a file the tree holds, so a block widens what resolves and cannot invent a target. Only the reading anchored at the page URL is answered from it, since the reading beside the source is a path and no URL at all: answering that one would clear docs/sources/administration/cli.md’s ../developers/http_api/user/, whose page URL is administration/developers/http_api/user and is claimed by nothing.

Grafana’s 278 becomes 149. Each of the 129 claims that went names a URL exactly one page declares, out of the 1,276 single-claimant URLs its 1,336 relative entries spell, and no claim arrived. The undecided class stayed at 1,153 rows again. Of the 195 paths that were missing, 152 resolve, and 23 of those become anchor claims because a path that resolves has its fragment read: ../../panels-visualizations/visualizations/time-series/#connect-null-values reaches the time series page, which publishes no such heading now that its options come from a shared file.

Which side of a comparison the declaration is read from decides whether a repository can adopt one at all. It is tree state, so the commit that writes the file has a base without it, and read each side its own way, Grafana’s adoption reports 476 claims resolved and 210 introduced, mostly anchors that become readable the moment their path resolves. Every one of the 210 was already broken and the author of that commit touched none of them. So both sides are read under the declarations the candidate holds. Grafana’s adoption then reports the 278 the tree has either way, all of them pre-existing, and nothing introduced.

Removing one is that rule read backwards, and that direction is reported. The candidate declares nothing, so neither side does, and Grafana’s 544 claims come back, every one pre-existing. What the symmetry cannot do by itself is say that a claim went: a fragment is read because its path resolved, and once the declaration goes the path is missing instead, or, under a built-route generator above the document, a declared boundary. So a declaration the base held that the candidate does not hold identically is a policy-weakened control finding at the file that held it, router/declaration-removed where the candidate declares nothing there and router/declaration-replaced where it names another router or another base. That is the one thing the declaration reports about itself, and it reports it in every profile.

The second key answers the destinations that open with a slash. Those are 54,717 of the 117,747 in-scope references across 56 repositories, 46.5 percent, more than the resolver answers, and each one names a page of a site rather than a file. Which site is the part no tree holds: the Kubernetes community repository writes /docs/comms/slack/ and means kubernetes.io. So the declaration says it. base is the URL path the directory it sits in is served at, and a route opening with that base is a path under that directory:

router: astro
base: /

Under that file at src/content/docs, /en/guides/astro-components/ is src/content/docs/en/guides/astro-components, which the extensionless spelling reads as the .mdx beside it, and the trailing slash names the page rather than a tree the way it does under page-url. Grafana serves docs/sources at /docs/grafana/latest/ and writes that prefix into its own links, so its base carries it and a route without it stays where it was.

The tree has to hold the page, on the side being read or, for a page a change deletes, on the base that served it: an unchanged link to a page the candidate dropped is then a missing target at that page’s path rather than an undecided route. A route that reaches no file keeps the boundary it had, because a site serves routes its own build makes up and a tree can enumerate only what it holds: Astro’s translated pages fall back to English, Kubernetes generates its API reference from OpenAPI, its blog is published at a permalink its sources do not spell, and its images come from a directory the declaration does not cover. All four stay declined, and across five declared trees not one missing-target claim arrived. What does arrive is anchors, because a path that resolves has its fragment read: 61 anchor claims against 15,890 references resolved, every one read by hand. Fifty-six name a heading the page it reaches does not publish, three name one a Hugo shortcode writes, and two name an anchor a generated reference page spells three ways. A declaration with no base line reads exactly as it did before.

A route naming a directory reaches that directory, since a tree holding guides/index.mdx holds guides, and a fragment on a directory stays undecided the way it already does for ../guides/#setup. Where Docusaurus, Starlight or Hugo publishes a page at the directory, its index or Hugo’s _index.md, the route reaches that page instead, and so does a relative destination naming the directory without a trailing slash. That is 1,106 of those references, with 302 fragments left unread. Nothing else about the reading is new: the destination is a path under the declared directory, read by the spellings every other path is read by.

Four repositories show what the file does, each read whole on 2026-09-20 under the observe profile, once as it stands and once with the file below staged.

Astro’s documentation keeps its pages under src/content/docs and serves that directory at the root of its site. Before its Starlight configuration was read, all 10,323 of its slash-rooted destinations were routes nothing in the tree answered, and the file went at src/content/docs/.amiss/router.yml:

router: astro
base: /

The router line turns on no spelling there, since built-route withholds an answer rather than serving a file, and the base is read whichever router the line above it names. Site routes fell from 10,323 to 1,647 and resolved references rose from 3,470 to 12,133. Three claims arrived, each an anchor on a page that only resolved then, and no missing path arrived at all. The configuration now says the same thing on its own, so the file moves nothing there today.

Kubernetes serves its website out of content/en and its hugo.toml says so, which is why that repository is no longer an example here: the configuration is read where it sits, and a declaration staged beside it moves not one reference. The section below is what answers it.

Jekyll’s own repository is why the row is not named after Hugo. docs/_config.yml sets permalink: "/:collection/:path/", which publishes every page at a directory of its own name, and one line at docs/.amiss/router.yml says so:

router: directory-pages

That resolves 19 references, 76 becoming 95, and moves no claim: the four that are missing sit in .github/, outside the tree the file covers, and stay missing. It is the smallest useful declaration on this page and the whole of what a Jekyll repository writes.

Grafana is the tree above, read again here on a newer clone than those paragraphs were written against. Both keys go at docs/sources/.amiss/router.yml:

router: directory-pages
base: /docs/grafana/latest/

Its claims fall from 546 to 153, and the 494 missing paths become 45. The 52 anchor claims become 108, since a path that resolves has its fragment read. Without the base line the count is 151 instead of 153, so the second key is worth two claims and 52 references on this tree, where on Astro’s, before its Starlight configuration was read, it was worth all 8,663.

Nothing in the output suggests writing one, and nothing honestly could. A tree says whether a generator is configured inside it; it never says whether its documents are published at all. So one structural fact covers both a repository built elsewhere and a repository with no site, whose missing targets are simply broken. A note keyed to that fact would have reached the Kubernetes community repository’s 254 missing targets, bat’s 21 and vuejs-docs’ one, none of which a declaration should touch.

That repository is also the one to read before writing a base, because its slash-rooted destinations do not name one site. communication/slack-moderation.md writes /docs/comms/slack/#reporting-a-problem and means k8s.dev, other pages mean kubernetes.io, and /sig-list.md and /CLA.md mean the repository root, which no site serves them from. A base: / at its own root reads all of them against the tree, and 737 references resolve where 828 routes were declined. Its 106 missing paths do not move, since a base can no more invent a target than any other spelling can, but 50 of the newly resolved paths carry a fragment the page they now reach does not publish, so 254 claims become 304. The key is one claim about where one directory is served, and a tree whose links point at three sites has none to make.

What this costs

A repository with no site at all now resolves ./guide when guide.md exists, and on github.com that link is a 404. This is the same trade the renderer rules already make for heading identities, taken for the same reason: a false missing target teaches maintainers to ignore the tool, and the union of what real renderers do is the honest way to avoid one. The three spellings are selected by nothing a repository declares. A generator rule is selected by a configuration file, which is a lever the pull request under review can pull, and the lever is bounded the same way the spellings are: it can move a destination onto a file the tree already holds, and it cannot clear a destination the tree lacks.

Routers outside the table serve spellings this check will not match. Four repositories built on them were run on 2026-07-26 to find out what a new row would have to answer, each read whole against an empty base under the observe profile. Three completed, at hugoDocs 620696ab3b07, jest f49721c78e19, and jekyll 7697d249793d, and their counts below are from those reports. The fourth, docusaurus 16f537309e35, produced no report: it ran to the end of evaluation and then refused at output, so nothing is counted from it. Of the three that completed, jest’s shape became a spelling and took a later survey to write, while Hugo’s and Jekyll’s became rows that declare a boundary instead of serving a path.

Hugo’s own documentation writes [glob pattern](g) and resolves g in its own render-link.html, 734 references to a path that exists nowhere. Its 101 missing anchors are two further mechanisms: 71 name a definition-list term, which its configuration turns into an identity with autoDefinitionTermID, so module.md’s files term is published as <dt id=files> while the pinned grammar has no definition list to read at all; 28 name a heading pulled in by an {{% include %}} shortcode. Jest, on Docusaurus, links a document by the identity that document declares in its own front matter: Configuration.md opens with id: configuration, its page is published at that name, and 104 references reach it by URL rather than by path. That one is the document-id spelling above, written after the same shape turned up across six ordinary projects. On a later clone it answered 127 of jest’s 130 missing paths, and the three left are real: tutorial-react is still in two versioned copies and gone from the current documents and the two newest copies.

Docusaurus itself refused at first, its findings serializing past the output reservation described in Limits and refusals. With that raised it scans, and with the identity its headings declare in an MDX comment now read it reports 198 missing references rather than 807. Those are the @site alias, a webpack path with no tree meaning, and identities that arrive through MDX imports of partial files.

Jekyll is the one that looks like a missing row, and the harvest says otherwise. Its own site writes reviewing-a-pull-request/ from a maintaining index, which reaches maintaining/reviewing-a-pull-request.md, and ../ubuntu/ from an installation page, which reaches nothing; jekyllrb.com serves the first and returns 404 for the second, so both of our answers match the site. A trailing slash reaching the sibling source file is real there because that site’s permalinks mirror its paths, which is a configuration and not a property of Jekyll. Asked the same destination, mdbook serves nothing, mkdocs rejects it in the source with a warning naming the .md file, and vitepress emits it verbatim into a build holding only page.html, dead on any host despite its own dead-link checker accepting it. One router, by configuration, is not a spelling. What that configuration does decide is where every page lands, and the built-route row says so rather than guessing at the template.

Hugo’s g was the case for the generated class, arriving as transclusion and as a repository’s own render hook. The exact-path core of that class is answered now, from the tracked ignore file recorded in Reference coverage, and both of those arrivals still sit outside it. What moved is the verdict rather than the class: a destination a render hook rewrites is undecided instead of missing, so hugoDocs went from 618 missing targets to 123, every one of them an anchor, and the anchor rules added since have taken it to none.

Last change: , commit: d7157396

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.

Correlation has an exact phase and a conservative candidate phase. Equal observation identities pair exactly. Among the remaining occurrences, a candidate edge exists only when the adapter, source construct, and CorrelationIntent projection agree.

What counts as agreement depends on the reference’s class. Repository paths and same-repository forge links share one semantic class, so an equivalent spelling change can still correlate; that class binds path, target kind, query, and fragment. External, site-route, and unsupported references keep their raw destination identity, with the external class binding scheme, query, and fragment, and the remaining classes binding their kind, query, and fragment. The correlation-intent vectors pin those fields and the GitHub, GitLab, Gitea, Bitbucket Cloud, and Bitbucket Data Center equivalence rows through the production projection in the vector test.

A candidate edge normally stays within one document. The only cross-document exception is a unique exact Git rename: exactly one removed path and one added path must share the same Git mode and raw-evidence digest, and the occurrence’s source projection must be unchanged. Duplicate document content disables rename correlation instead of forcing a tie-break. The correlate integration tests fix the matching boundary, while the amiss-scan correlation benchmark tracks its scaling.

The candidate edges form a bipartite graph. A component with one occurrence from each side is a candidate match. If multiple counterparts are possible, the result is an observation-correlation-ambiguous finding with attribution unknown; Amiss never chooses one by input order. An occurrence with no counterpart is new or removed. Repeated equal findings are subsequently merged into one fact carrying a multiplicity count.

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 selected target projection 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

A reference into its own page has that page as its dependency: a same-page fragment, or an empty destination carrying an attribute list the way MkDocs declares [](){#id}. Any edit anywhere on the page would change it. A page cannot go stale against itself, so such a reference keeps its target change and its story is the block’s alone.

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; it is recorded without asking for review because the source edit already shows the removal. document-removed likewise records that the whole file left the tree.

Formatting noise stays out by construction. Amiss does not normalize referenced content: for a whole-file reference, any change to the target bytes or file mode is a change; for a numeric line fragment, any change to the file mode or to bytes inside the inclusive selection is a change and bytes outside it are not. A reference whose fragment or query the run could not answer, a symbol in a code file, a ?plain=1, an anchor on a page whose identities are partial, still located its file, so it is compared as a whole-file reference rather than left out of the comparison. 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 its source bytes with line endings made LF, so a re-wrapped paragraph reads as a changed subject. That costs a subject-changed record and nothing more: a failing reference keeps its attribution through the pairing, as Profiles and findings describes.

Last change: , commit: 5c11646e

Claims

A document can do more than point at a file: it can pin what the file says. A value claim is a reserved reference definition asserting that one line of one repository file, terminator aside, is exactly one expected text. The scanner evaluates the claim on every run, so the page that states a version number or a default stops being a promise and becomes a checked fact. Authoring one is a single command: amiss claim reads the line and prints a definition it has already proven against the extractor and this grammar.

The grammar

[amiss:release-version]: <amiss:value?path=Cargo.toml&line=L3> "version = \"0.16.0\""

The grammar is closed, and every clause of it is load-bearing:

  • The label after amiss: names the claim. A name starts with an ASCII letter or digit, continues in letters, digits, ., _, or -, and holds at most 120 bytes. The name heads the finding’s rule id, claim/value/<name>, so it carries no slash.
  • The destination is angle-bracketed and spells amiss:value with exactly two parameters, path then line. The path is repository-root relative, taken byte for byte with no percent decoding, and must satisfy the repository path grammar. The line is L followed by a number with a nonzero first digit, at most sixteen digits, within the safe integer range.
  • The title carries the expected text, decoded by the CommonMark title rules. An empty title is lawful and claims an empty line.

A reserved definition that misses any clause is not a lesser claim: it stays an unsupported capability, and the run ends incomplete with exit 2, exactly as before the value kind existed. A reference definition is invisible in rendered output, so a claim adds nothing to the page a reader sees.

The carriers

Every structured format has an invisible construct that carries the same line. Markdown and MDX use the reference definition above. reStructuredText uses a comment holding exactly the one carrier line, and AsciiDoc a line comment:

.. [amiss:release-version]: <amiss:value?path=Cargo.toml&line=L3> "0.16.0"
// [amiss:release-version]: <amiss:value?path=Cargo.toml&line=L3> "0.16.0"

The comment carriers take their bytes literally: no entity decoding, the title in double or single quotes, nothing after the closing quote, the comment at column zero, and in reStructuredText nothing but blank lines after the carrier line, since a comment holding anything more stays opaque exactly as before. The one recognized spelling is the whole carve-out from comment opacity. amiss claim prints the Markdown line; prefix it with .. plus one space for reStructuredText or // plus one space for AsciiDoc, and a broken claim’s fix respells whichever carrier it lives in, marker included.

Evaluation

The claim’s target must be a regular or executable blob in the candidate snapshot. The line answers without the terminator that ended it, whatever spelling that terminator used, and the answer must equal the expected text byte for byte. Reading the target charges the same referenced-target and line-fragment budgets a reference line fragment charges, through the same cache, so a claim and a link to the same file cost one read.

Claims are evaluated on the candidate side only. A claim speaks in the present tense about the tree it rides with, so there is no pre-existing exemption to ramp away: its attribution is not-applicable, and the enforce-introduced profile treats a broken claim exactly as enforce does.

Outcomes

An attested claim produces no finding. The summary counts it in governed_claims, and unattested_claims counts the claims that failed, so the two numbers say how much of the document’s asserted surface held.

A claim that fails produces one of two findings, warn under observe and fail under both enforce profiles:

  • claim-broken: the target line exists and says something else. The finding’s evidence carries the expected and observed digests.
  • claim-target-missing: nothing can answer, and the evidence names why: the path is absent, the target is not a blob, the target is an LFS pointer, or the line is out of range.

Claims sharing one name in one document aggregate per outcome kind: every broken member joins one claim-broken finding and every unanswered member joins one claim-target-missing finding, each carrying its contributing source digests, the way governed boundaries already aggregate.

The fix a broken claim carries

A claim-broken finding standing alone carries a machine-applicable fix: the whole carrier respelled with the observed line as its expected words, marker included, the byte span of the carrier to replace, and the document that holds it. The expected words are looked for first. A line inserted or removed above the claimed one moves them, so where exactly one other line of the target holds them, the fix follows them there: the line number is respelled and the words are kept. Where several lines hold them, nothing says which one is meant and the field stays null. Only where no line holds them does the fix take up the changed value. The engine emits the fix only when it can prove it: the rewritten carrier is parsed back through the real extractor for its own format and must classify to the identical claim with the new expected words, so an observed line the quoted-title grammar cannot spell (a double quote, a backslash, a control byte, or bytes outside UTF-8) leaves the field null, and so does a name whose definitions aggregate, since grouped members share one finding but not one edit. A claim-target-missing finding never carries a fix, because nothing derivable says where the target went.

Policy-owned projections

A projection checks visible example text rather than an invisible expected title. Its relation lives in .amiss/scanner-policy.json:

{
  "document": "docs/api.md",
  "name": "request-shape",
  "projection": "code-text-v1",
  "sink": "previous-code",
  "source": {
    "kind": "blob-lines",
    "path": "examples/request.json",
    "first_line": 1,
    "last_line": 12
  }
}

The Markdown or MDX document addresses the visible sink with an invisible definition immediately after its code block:

```json
{"operation":"check"}
```
[amiss:request-shape]: <amiss:projection>

Whitespace may separate the code block and marker; prose or another node may not. The name is unique within the document and joins the policy row to the marker. It is deliberately not encoded in the destination query, and neither the marker nor the source file owns the relation. That separation gives deletion safe behavior: a missing marker is drift while the policy survives, and a removed policy identity is policy weakening.

code-text-v1 converts CRLF and bare CR endings to LF, then removes exactly one terminal LF to match the parser’s semantic code value. Every other byte, including indentation and trailing spaces, remains significant. The first selector shown above takes inclusive raw source lines. A movement-stable selector can instead name the bytes between two complete marker lines:

{
  "kind": "named-region",
  "path": "examples/request.json",
  "start_marker": "// amiss:request:start",
  "end_marker": "// amiss:request:end"
}

Each marker is a distinct exact printable-ASCII token of at most 256 bytes, and the complete line equal to that token must occur once. The scanner interprets no surrounding comment syntax or embedded occurrence as a boundary. It excludes both complete marker lines and refuses duplicate, missing, reversed, same-line, or non-UTF-8 regions as typed projection drift. Edits outside the region do not affect its projected digest. Both selectors use the existing bounded target cache and line-fragment meter; source bytes are never executed or fetched.

A whole file is a source too, which is how a committed --help snapshot stays equal to the block that shows it, and so is one value from a configuration file, named by the keys that lead to it:

{ "kind": "blob", "path": "docs/help.txt" }
{
  "kind": "key-value",
  "path": "Cargo.toml",
  "format": "toml",
  "key": ["workspace", "package", "version"]
}

The file is parsed as TOML or JSON, and the keys walk tables or objects from the root to a string, number, boolean, or TOML date. The value is projected the way the file’s own grammar prints it: a string as its text, anything else as written. A missing key is source-key-absent, a key naming a table, array, or null is source-key-not-scalar, and a file its format cannot parse is source-unparsable. Both sources read the target through the same budgets a line selection does.

contains-v1 pairs with any of these text sources and asks only that the visible block hold the value somewhere, byte for byte after the same line-ending rule. That keeps an install line honest:

```sh
cargo install --locked amiss --version 0.35.0
```
[amiss:install-version]: <amiss:projection>

With "projection": "contains-v1" and the key source above, the block fails as content-absent the day the package version moves and the line does not. An empty value sits inside every block.

A complete tracked-path inventory uses the same visible sink without reading another source blob:

{
  "document": "docs/examples.md",
  "name": "examples",
  "projection": "sorted-rows-v1",
  "sink": "previous-code",
  "source": {
    "kind": "tree-paths",
    "root": "examples",
    "suffix": ".md",
    "maximum_depth": 2
  }
}

The root must exist as a tree in a commit or as a directory implied by the staged index. The source filters the discovery map Amiss already completed: descendants at or above maximum_depth, with an optional exact suffix, become root-relative UTF-8 rows. Regular, executable, symlink, and gitlink paths participate; tree entries themselves do not. sorted-rows-v1 orders those rows by UTF-8 bytes and joins them with LF, without a terminal LF. No glob, repository rewalk, object read, normalization, suffix stripping, or source-language rule is involved.

A qualifying non-UTF-8 path or path containing a control character refuses the projection instead of disappearing from or splitting the authoritative set. A path outside the repository grammar already makes the candidate globally incomplete before projection evaluation.

On a mismatch the report retains exact expected, observed, missing, and extra row counts and distinguishes a pure ordering defect. It copies at most the first 32 missing and 32 extra byte-sorted rows, at most 32 KiB per side, and states the exact omitted counts; duplicate visible rows count as extras.

decimal-count-v1 uses the same complete tree selection but projects only its member count. Its visible value is the canonical unsigned ASCII decimal: zero is 0, a positive count has no leading zero, and signs, grouping, labels, and whitespace are never accepted. Because no path text enters that projection, qualifying non-UTF-8 and control-containing names still contribute to the exact count. A mismatch reports the expected count and the observed count only when the visible value is itself canonical.

An attested projection emits nothing. Every nonattested relation emits one projection-drift finding under claim/projection/<name>, points at the visible code block when one is uniquely addressable, and carries only digests and byte counts rather than copying either full value into the report. No projection fix is emitted: a later rewrite feature must first prove an exact editable content span, reparse the whole document, and re-evaluate the relation.

Last change: , commit: dd6bc030

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 has three members: its schema, the payload, and payload_digest, a hash of the payload’s canonical bytes. The payload carries its own schema, compatibility (the wire’s own version, 3 since the tolerant-reason reshape), and an engine block whose engine_digest names the binary that produced it. 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 cannot 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 PR-facing feedback projection; 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.

Both report readers use the same result model and verdict check. They require every result member and reject unknown members, negative counts, and disagreements between complete, status, and exit_code. Bootstrap also checks the recorded finding count against the array.

Summary and result counts are limited to 0..=9007199254740991 by their Serde models on both input and output. They remain ordinary u64 fields in Rust; serialization refuses an out-of-range count rather than emitting a number the report reader cannot accept.

Bootstrap decodes the complete report once, including document, observation, finding and error rows, and passes the typed evaluation and controls through verification. After LF framing, malformed fields fail the shape check before canonical bytes, digests and semantic bindings are checked. Validly shaped engine, identity, control and verdict mismatches retain their specific errors. Exact canonical byte equality prevents accepting a differently shaped input with the digest of its typed normalization. Unknown payload-root fields are rejected by both readers.

The public reader also decodes the complete model. It accepts whitespace, member reordering and equivalent JSON string escapes, but not changes to the data’s shape. At ingress, serde-transcode feeds the original JSON directly to the canonical writer; a domain-separated SHA-256 check compares that envelope with the typed envelope before its payload digest is trusted. This prevents a positional array, dropped member or normalized field from borrowing the digest of a different report, without retaining a dynamic payload or staging two output buffers. Malformed fields and unsupported compatibility tags return the report-shape error; validly shaped payload-digest and result-tuple mismatches retain their specific errors.

Engine, action, adapter and summary metadata also use closed models. Unknown members are rejected even with a matching payload digest. Bootstrap validates the summary’s declared fields as well as the engine block; a local action tag cannot conceal a forge-action body.

Document, feedback and analysis-error rows reject unknown members too, including nested source spans and byte-path objects. An unavailable feedback tag cannot hide available items. Writers retain explicit nullable members. Direct Serde decoding treats an omitted ordinary Option<T> field as None, like null; report admission still checks the original envelope against its typed canonical representation, so omitted output members cannot borrow its digest.

Resolution tags select closed bodies: fields belonging to another kind are rejected, and targets, blob evidence and version scopes reuse the producer’s types.

One value inside those bodies is not closed. A reason says why an answer could not be given, and the answer itself sits beside it: a document row’s unsupported_reason next to its status, an unsupported-semantics row’s reason next to its kind. A consumer that meets a spelling it has never seen still knows the document went unscanned, or the reference went unevaluated, so those two are open strings inside the major. A reader keeps a spelling it does not know and writes it back byte for byte, which the payload digest requires of it. The unsupported-semantics row is one shape for every reason, the reason and the target it located when it located one, so a new reason needs no new row; the engine writes a target for query, code-fragment and fragment and for nothing else.

Everything a consumer has to name in order to judge stays closed and still refuses a value it does not know: every kind, disposition, status, schema name, and compatibility itself. The missing reasons stay closed as well, since each one selects which evidence its row carries and each feeds the finding key.

Observation comparisons, occurrences, intents and identity inputs are closed too. An occurrence is its identity input plus its outcome: observation_id_input carries the document, adapter, source construct, source projection digest and extracted intent once, and beside it sit only the observation id, the resolution, the source span, the block kind and any external destination. A comparison’s sides says what each tree held: same carries the one occurrence both trees hold, and each carries a base and a candidate occurrence, either of them null for a side that has none. An each pair whose two occurrences are equal is not the writer’s spelling and is refused. The refs command retains the decoded models for querying and output; it does not reparse individual occurrences or retain unknown extension fields. Candidate and alternative order, byte-path spelling and canonical JSON output remain unchanged.

Finding-key scopes use the enum’s own tag and reject unknown members, including nested target intents and occurrence identities. Finding keys are hashed through the canonical writer, so Rust field declaration order does not affect identity or adoption reproduction.

Projection sources reuse the policy producer’s model through report decoding. Their tags select closed bodies; a record-set tag cannot hide a record-value selection. A tree-path suffix may be absent or null when decoded directly. Writers omit an absent suffix; report admission rejects an explicit null that would change during typed canonicalization.

Projection differences and debt/waiver diagnostics also use their enum’s own tag to select a closed body. Diagnostic trees must be objects. A count’s observed_count and a waiver’s current_fact_digest are emitted even when null. Canonical fact bytes stay unchanged.

Finding facts reject unknown members throughout their evidence and control-state objects. One tagged evidence enum serves both report construction and borrowed adoption preimages; the latter keeps the original resolution borrowed. Site-defect identities use its derived kind label, preserving their kind/route preimages. Embedded document rows and observation comparisons must be objects, not positional arrays.

Finding rows, locations, aggregation, fixes, policy steps and applied debt/waiver metadata reject unknown members. Evaluation carries applied exceptions directly into the report; it retains their provenance without cloning the accepted or authorized fact again. Nullable finding fields remain explicit on output, and canonical report bytes stay unchanged.

The evaluation records candidate_ref and target_ref separately. The candidate ref is the source branch used for same-repository URL resolution; the target ref is the protected branch to which branch-scoped controls were matched. Either may be null on a local, self-asserted run, and the direct CLI currently leaves the target null. Both values enter the candidate-identity preimage. They describe the exact inputs the engine evaluated; their presence does not prove who selected or authenticated them.

Candidate identity is serialized from the same typed evaluation carried by the report, with evaluation_instant and trusted_time excluded and the candidate-identity schema added. The report reader and sealed acceptance reject unknown evaluation and snapshot members, as the published schema requires; they never discard extra identity data before hashing it. Missing clock fields fail the canonical comparison; wrongly typed clock fields fail decoding. A null instant or a string that disagrees with the trusted statement fails control verification.

An exact same-repository forge URL carries an optional commit_oid in its target intent and finding-key projection. The engine resolves it only from that commit’s objects already available in the declared Git roots. If the commit or any required object is unavailable, an unsupported-version resolution retains known-commit with the exact ID and contained path; it does not turn unavailable evidence into a missing target, and the decoded URL is retained for the provider-evidence layer. Named refs and ambiguous ref/path splits retain the narrower known-path or unknown-path forms instead of being guessed into a commit.

The sealed commit-pair path, including every provider lane, still reports explicit-commit-pair and explicit-replay. Provider event and publication facts remain outside the engine report.

A repository path anywhere in the payload has exactly one spelling. Valid UTF-8 bytes travel as a plain string; anything else travels as {"bytes": [...]}, the raw bytes as an array of byte values. A writer never uses the object form for bytes that decode as text, so every derived digest stays whole.

external_destination holds the URL retained by an external or unavailable historical resolution. Most such URLs delegate evidence to another layer: an external URL or a same-repository exact commit whose required local objects are unavailable. The occurrence keeps the URL after the format’s own decoding so that https://example.com/x?a=1&amp;b=2 is recorded as the address a fetcher would request rather than as the bytes the source spells. A locally resolved or disproved historical target has no delegated destination. An ordinary external URL raises no finding and the summary counts it under external_out_of_scope, because the engine never fetched it and so decided nothing. The summary counts every candidate reference twice over: once by what it names, explicit_local, same_repository or external_out_of_scope, and once by what the run concluded, resolved, missing, or unsupported for one it declined to judge, whatever it names. A Sphinx label resolved through candidate-bound inventory evidence carries both the selected destination and reason: "intersphinx-inventory", but counts as resolved and is not delegated again. A generated route proved by complete site-build evidence similarly carries reason: "site-build", counts as resolved, and has no external_destination because it names no external target. An occurrence whose target misses a heading anchor or a label carries fragment, the text its fragment_digest digests, so a reader can name what is missing without the tree. It stays outside the observation identity and every fact, so no key or fact digest moves with it. The external plan derives the introduced and removed destinations from a written report, and Amiss and link checkers shows the pipe that hands them to the tool that does fetch.

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 too, so an external system can recompute any finding’s identity from the report alone.

A row is one finding key, and the key’s scope decides how much one row covers. A missing or mistyped target keys on the document, the exact target, and the block holding the reference, so a link one block repeats is one row, and each block that repeats it has a row of its own. The four records that answer for a single reference, target-declared-untracked, unsupported-reference-semantics, unsupported-target-kind, and unsupported-version-scope, key on the document alone. That makes one row per document and kind: aggregation.member_count counts its references, observation_ids names each of them, and the location is the first. Each reference keeps its own resolution and reason on its observation row. An invalid reference keeps a row of its own, since it carries an attribution. aggregation.locations_omitted counts the members whose place the row cannot point to: none where observation_ids names every member, and all but the representative for a claim or control row, which names no observation.

feedback is the smaller review surface derived by the engine from those exact findings. Related introduced problems become one fix per target, and so do problems whose attribution is unknown, since enforce-introduced keeps those blocking. Changed targets under unchanged prose become one check, and pre-existing failures and warnings become one existing per target after them, so the backlog is listed, not only counted; existing_count stays the number of those grouped subjects. Each item retains its affected-location count and contributing finding kinds. A Fix may carry one candidate-side text-path annotation; Checks and pre-existing items never do. The report retains every item. An incomplete comparison instead emits exactly {"status":"unavailable"}, so scan failure cannot look like zero feedback. The evaluation identity follows the same rule: a side that never resolved to a commit and its tree is {"kind":"unavailable"} with the reasons it did not, while a side that did keeps its real identity, so a commit ID never stands in for a tree ID.

The envelope, down to its top-level keys:

{
  "schema": "amiss/scanner-report-envelope",
  "payload": {
    "schema": "amiss/scanner-report-payload",
    "compatibility": "3",
    "engine": { "engine_digest": "sha256:..." },
    "evaluation": {},
    "controls": {},
    "result": { "status": "fail", "complete": true, "exit_code": 1 },
    "feedback": { "status": "available", "items": [], "existing_count": 0 },
    "summary": {},
    "documents": [],
    "observations": [],
    "findings": [],
    "errors": []
  },
  "payload_digest": "sha256:..."
}

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

{
  "kind": "explicit-target-missing",
  "description": "a reference names a repository path, a line range inside one, or a heading anchor no known renderer publishes; restore the target or correct the link",
  "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. Every immutable commit identity is part of that scope, so equal paths in two commits remain different targets for correlation, findings, debt, and waivers. A reference kind with no repository-path component retains the empty-string sentinel in normalized_target_intent.path; a repository path uses the ordinary text-or-byte path form. Every finding and error row carries a description: the fixed engine-owned sentence for its kind or code, stating what the row means and what to do about it, so no consumer needs a second source to act on a report. Beside it sits fix, a machine-applicable rewrite or null, whose own description is one of a closed set of engine-owned sentences named by FixKind: when the engine can prove the exact edit, the field names the candidate document, the byte span to replace, and the replacement text, and a finding whose correct content is not derivable carries null rather than a guess. Three producers emit one today: the broken value claim carries its definition respelled to expect the target’s current line, proven by classifying the rewrite back through the claim grammar (see Claims), and a lone drifted heading anchor carries its fragment respelled to the one published identity it names apart from case and separator style, over bytes the adapter located verbatim, and a lone case-drifted path carries its written path part respelled to the one tracked path it matches apart from case. amiss fix applies these spans to the staged working tree in place, refusing any document whose bytes moved since the evaluation. The sentences live in one place, FindingKind::meaning, AnalysisErrorCode::meaning, and FixKind::meaning; the lists in Profiles and findings and Analysis errors and the shipped example are checked against that source in CI. The human format prints the result plus at most ten grouped feedback items, replaces every character outside printable ASCII with a \uXXXX escape, and every such byte of a path that is not UTF-8 with \xhh, so a hostile filename cannot inject terminal control codes or a forged CI command into a log and a raw byte never reads as the character sharing its value, and states any overflow explicitly. It keeps raw totals and prints each sentence once as a note line, for every error code and every finding kind the run reports, while the rows themselves stay in JSON. The places under a row read by document, then line, then column, so a reader can scan down to one, and two places at a single position settle on the finding key, which the report holds distinct. The findings array itself keeps its finding-key order, since that is the identity a consumer matches across runs and a location order would move a row whenever a line moved. The JSON is never cut short: a serialized report that would cross the 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.

--format sarif writes exactly one line to stdout: a SARIF 2.1.0 log projected from the same payload. Every finding row becomes a result under its kind’s rule, fail as error, warn as warning, and record as note. The message leads with the row’s own words, the kind, reason and target a human place line prints, and then its description, so two broken links under one rule read apart; a recorded row, which shows no place, carries the sentence alone. Each rule carries that sentence as its help, linked to Profiles and findings. A row carrying a fix projects it as a SARIF fix with the byte region and replacement, and the finding key rides as a partialFingerprints entry, so a consumer that reads either matches results across runs by the identity the report uses. GitHub code scanning reads neither, as CI explains. A row located in the base, such as a reference the change resolved or removed, is left out: the line it names is gone from the candidate, and a code-scanning tool closes an alert only when its row stops appearing. A location’s URI is the path’s own bytes percent-encoded, so a hostile path cannot break it and a name that is not UTF-8 still names its file. The run declares columnKind as unicodeCodePoints, the unit every report column counts. Retained analysis errors become tool execution notifications, an incomplete run reports executionSuccessful false, and a rejected machine invocation still answers in SARIF with exit class 2. Like the human form, the projection cannot change facts, ordering, totals, or the exit class; the canonical report stays the only wire, and consumers that need the full evidence read it there.

--format codequality projects the same payload as GitLab’s Code Quality artifact: a JSON array with one issue per finding row in report order, leaving out rows located in the base for the same reason, the row’s own words and then its description as the issue text, as in SARIF, its kind as check_name, and fail as major, warn as minor, and record as info. The finding key rides as the fingerprint, so GitLab’s diff of target against head recognizes the same finding across runs by the identity the report uses. GitLab requires a path and a first line on every issue, so a byte-named document answers with the wire’s hex spelling, a finding on no file answers as (global), and a byte-only span reads as line one. The format has no shape for analysis errors or a refusal: a rejected invocation answers with a valid empty artifact, the exit class still carries the truth, and error detail stays on the JSON and human lanes. The same projection bounds apply.

A render-only --format junit projects one suite for generic CI test dashboards. Findings become cases under their stable finding keys: fail becomes a failure whose message leads with the row’s own words as in SARIF, while warn and record remain passing cases with their exact disposition in system-out. Retained analysis errors become error cases. A row-free passing report emits one passing report case so the artifact remains visible. File attributes carry only text that round-trips exactly through XML 1.0, and time is always zero because the report records no duration. JUnit remains display data, since the renderer’s exit is still the report’s recorded verdict.

A JSON report can be projected later without repeating repository evaluation: amiss render --report amiss-report.json --format sarif, --format codequality, or --format junit emits an alternate deterministic view. SARIF and Code Quality are byte-identical to their originating check projections; JUnit has no direct-check form. Human is available too; adding --full to that human replay emits every feedback row rather than the two ten-item windows. The renderer verifies the active envelope and compatibility, the payload digest, and the recorded result tuple, then exits with that report’s original verdict. JSON is not a render target because the input file is already that canonical projection.

The report is evidence of engine evaluation, not a self-authenticating provider attestation. A control row with status: "verified" means that the engine accepted the supplied digest and repository, target-ref, tree, time, or run relationships required for that control. A caller that can supply the request can still make those assertions; the enum does not identify or authenticate the caller. The sealed bootstrap additionally checks the requested identities and digests against the returned envelope, but republishes the accepted bytes unchanged. The controls.semantic_evidence array similarly records each accepted envelope’s payload digest and producer/input identity; it proves engine binding and interpretation, not who acquired the inventory.

The provider lanes leave separate provider evidence: an App-owned Check Run on GitHub’s test merge, a protected GitLab policy-job result on a merge-train commit, or a dedicated Gitea-family review. GitHub’s Check Run and the Gitea-family review carry the staged summary and report digest; GitLab’s provider-visible evidence is the exact policy job’s outcome. The controller’s saved result binds the plan, execution constraint, and gate identity in every lane. When a report is present, it also binds the report digest. No provider signs or adds fields to the report. Moving the same report bytes away from that gate therefore loses the provider context; there is still no provider attestation inside the current report contract.

Sandbox provenance is separate again. The present writer reports self-asserted assurance, local-process enforcement, and null verification. The sealed bootstrap requires that honest projection. Runtime-closure validation, a cleared environment, fixed input, and a watchdog do not satisfy the report schema’s provider-verified OCI or microVM mechanisms.

Control provenance retains the concrete execution descriptor, trusted-time statement and semantic producer metadata. Readers reject unknown control and sandbox members, including when the report’s payload digest matches. Sealed acceptance additionally requires verified controls and an explicit evidence list, then checks the retained bodies and their independent bindings. It never reconstructs those bodies from arbitrary JSON maps.

The machine contract is the current report schema, its readable example, and the corresponding canonical bytes. The test suite validates emitted bytes with an independent schema validator, checks the canonical example, and checks that the schema identifiers match the writer constants in the documentation contract test.

The wire is versioned by its own compatibility field, not by the engine release: 3 since the tolerant-reason reshape, additive within the major. A 3 report may gain optional fields and new reason spellings as 3 rolls forward, and nothing a 3.0 consumer parsed ever changes meaning or disappears. The promise is mechanical: the frozen example that opened the major is retained permanently beside the rolling one, a contract test requires every later schema in the major to keep validating it, and a second test holds the example the last release shipped to the same bar. Reshaping past that promise mints 4, and that release is a major one. Every reader of 3 refuses a 2 report: the render and refs verbs, the external plan, and the controller. The record of how the contract earned the freeze, and of the reshape that minted 2, is in A settled wire; the record of 3 is in A reason may grow.

Last change: , commit: e94587f1

The external plan

A report retains a destination when its resolution delegates evidence to another layer: an external URL the engine does not judge, or a same-repository exact commit whose required objects are unavailable locally. The external plan is the pure derivation that turns one written report into the work another layer may do: which distinct delegated destinations this change introduced, which it removed, and where each one lives.

amiss external-plan --report report.json --format json

The command opens no repository and touches no network. It reads the report file, verifies the payload against the digest the envelope records, and refuses anything less than a complete report. The digest proves the payload is whole and untampered relative to its own envelope, so corruption and truncation are refused; where the file came from is the caller’s supply chain, as it is for every other input. The derivation is set-wise per side: a destination counts as introduced when the candidate references it and the base does not anywhere in the tree, and as removed in the mirror case. A destination that only moved between documents is neither; it is counted under retained_count and never listed, which keeps the plan proportional to the change rather than to the corpus.

Each row carries the destination exactly as the report recorded it, after the format’s own decoding, the address an evidence producer would request; its lowercased scheme; and the sorted documents naming it. Unavailable exact history uses https, the only scheme accepted by the same-repository forge grammar. A destination on a forge host the run can name, github.com, gitlab.com, codeberg.org, bitbucket.org, or the report’s own declared host under its declared dialect, also carries a repository object: host, dialect, owner, name, form, and opaque tail. The ordinary forge grammars take owner and name from their leading path segments. Bitbucket Data Center instead recognizes an optional installation context followed by the first projects or users repository route; its project key or personal slug becomes the owner, and browse is the form. The destination still retains the revision query verbatim. Tails stay unsplit where branch names may contain slashes, so separating revision from path needs the other repository’s refs, and naming structure is not claiming the repository exists; both belong to the verifying layer. The payload binds the report’s own payload_digest and echoes its evaluation identities, and the plan envelope carries a digest of its own payload under the plan schema identity. A producer that probes the introduced list, and any later judgment over that evidence, can therefore join plan, report, and evidence on one identity without re-reading the tree. The schema is scanner-external-plan.schema.json, and its example is derived from the report example by the same code path, checked in CI.

The plan states work; it performs none. Fetching stays outside the engine for the same reasons What Amiss is not gives for live URLs: a probe’s answer varies with the network’s mood, and a guessed pass looks exactly like a real one. What a producer observes comes back through the external assessment, and the composition with a checker that does fetch is one pipe, shown in Amiss and link checkers.

Exit 0 wrote the plan, human or JSON. Exit 2 means the input could not be trusted: unreadable, larger than a scanner report can be, not valid JSON, not a report envelope, a payload that fails its recorded digest, an incomplete report, or an eligible occurrence missing its destination, document, or required scheme. There is no exit 1, since a plan carries data and no verdict.

Last change: , commit: bdc81786

The external assessment

The external plan names work; the assessment judges what came back. A producer probes the plan’s introduced destinations or asks a forge API about the shaped ones, and writes its observations into an evidence file. Two producers ship in this repository: the provider lanes verify shaped destinations through their own APIs, and amiss-probe --plan plan.json probes the unshaped https ones, every URL and redirect hop vetted and address-pinned before a byte leaves the process. Any other producer works too. The engine then judges offline:

amiss external-assess --plan plan.json --evidence evidence.json --format json

Evidence carries observations, never verdicts. A probe row reports the final status or the transport failure, exactly one of the two, the method that saw it, and where redirects ended. It marks that destination as a permanent retarget only when every observed hop used 301 or 308 or was an instant refresh. A redirect stub often answers 200 with <meta http-equiv="refresh" content="0; url=..."> in its head, so the probe reads the first 16 KiB of a page that answered with HTML and follows a zero-delay refresh as one more hop. A refresh with a delay is a page the reader sees first, and it moves nothing. A forge row reports what the API said: the repository’s visibility first, then how the opaque tail resolved against its refs. The file binds the exact plan by payload digest, and the discipline is strict in both directions: a row naming a destination the plan did not introduce, repeating one, or binding another plan refuses the whole run, while destinations the file never mentions simply stay unproven. The schemas are scanner-external-evidence.schema.json and scanner-external-assessment.schema.json, and the assessment example is derived from the plan and evidence examples by the same code path, checked in CI.

The judgment policy is fixed in the engine and deliberately conservative, because the web’s refusals outnumber its deaths. A 404 or 410 refutes only when a GET confirmed it, since servers drop HEAD requests they would answer. A 401, 403, 429, or LinkedIn’s 999 is a wall, not a grave: unproven. Transport failures, unfollowed redirects, and absent evidence are unproven too, each with its reason named. On the forge side a missing repository never refutes, since forges answer 404 for private repositories they will not name; refutation needs a readable repository whose refs resolved and whose path or revision then proved absent. Every redirect destination remains evidence, but only a permanent chain ending on a page that answered lands as a retarget suggestion on the row, never a finding or an automatic edit. A permanent move onto a page that is gone offers no edit. And reachable claims exactly what it says: something answered, not that the content is still right.

The plan names what a change introduced, so a link that rotted on its own is never probed. A full audit makes every destination introduced by reading the tree against an empty commit:

base=$(git commit-tree "$(git hash-object -w -t tree /dev/null)" -m empty)
amiss check --repo . --object-format sha1 --base "$base" \
  --candidate "$(git rev-parse HEAD)" --profile observe --format json > amiss-report.json
amiss external-plan --report amiss-report.json --format json > amiss-plan.json
amiss-probe --plan amiss-plan.json > amiss-evidence.json
amiss external-assess --plan amiss-plan.json --evidence amiss-evidence.json

The -w matters: git knows the empty tree without storing it, and Amiss reads only the object store. The commit is dangling and costs nothing. The probe still stops at 64 destinations or two minutes, whichever comes first, and names what it left unproven on stderr, so a large tree takes several scheduled runs.

Every verdict row echoes the plan’s document attribution, and the subject block binds report, plan, and evidence digests, so the same three inputs always reproduce the same assessment, digest included, and a lane can replay the whole chain from artifacts alone. Exit 0 wrote the assessment, refuted rows included. The command itself remains advisory; a consumer decides what those rows do. Human output shows up to ten permanent-retarget suggestions and names any overflow; JSON retains every row. Exit 2 means an input could not be trusted.

Provider plans expose that decision as external_policy. off makes no external API calls; advisory, the default, retains and counts the assessment without changing the engine result; and block-confirmed-refutations changes a passing provider result to block only when the retained assessment contains at least one refuted row. An incomplete assessment, unproven row, authentication or rate-limit wall, private-repository 404, transport failure, missing evidence, or reachable row never changes the engine result. The blocking mode is an opt-in pilot: review a lane’s retained advisory evidence over time before enabling it. Arbitrary HTTPS remains the separate advisory experiment described in Continuous integration.

Provider lanes retain the canonical plan, provider evidence, and assessment beside the exact provider-bound report before the final provider refresh and publication stage. The policy is part of the controller plan digest. The published assessment digest and artifact locator therefore name one frozen chain and one frozen decision. A lost provider reply or service restart verifies and reuses those bytes without another API probe; incomplete verification is retained as incomplete rather than reconstructed later. If the final refresh finds a changed head or gate, the staged result is superseded even when the retained assessment had refuted a destination. Authorization, expiry, and capacity are defined in Retained provider artifacts.

Last change: , commit: 1296d63b

Trusted semantic evidence

Some documentation identities exist only after another tool has done work the repository tree cannot represent. A Sphinx inventory maps foreign object names to published URIs. A completed site build owns generated routes, anchors, redirects, versions, locales, and navigation. Amiss does not execute either producer inside the engine, but those different producers need the same trust and replay boundary.

The semantic-evidence envelope is that boundary. Its payload binds:

  • the scanner candidate-identity digest, which already covers repository identity, refs, both snapshot materializations, and forge semantics;
  • an optional source-report payload digest when evidence was derived after a scan;
  • the producer kind, stable implementation identity, version, independently selected semantic context, and the kind-defined digest of all inventories or completed-build input;
  • whether the producer completed that exact input;
  • at most 100,000 observation objects, sorted by canonical JSON and unique.

The envelope carries the domain-separated payload digest and is limited to 16 MiB. Its strict reader refuses malformed JSON, unknown fields or producer and observation kinds, invalid identities, duplicate or unsorted observations, oversized input, and a mismatched digest. Construction sorts observations once so a filesystem, inventory, or build traversal order cannot change the evidence identity.

Producer kinds are the closed set sphinx-inventory-set, site-build, and record-set. Templates, controller acquisition expectations, and report provenance use the same set. Implementation identities remain producer-owned; a new producer family requires a compiled consumer and a corresponding contract update.

Observation vocabularies do not share a synthetic universal graph. An Intersphinx producer needs domain, role, object name, inventory identity, and URI. A site-output producer needs routes, anchors, redirects, navigation edges, and source attribution. Envelope and template readers decode observations directly into the existing closed site, Sphinx label, or record-set models. Unknown kinds or fields and positional observation arrays are rejected at that boundary. Binding a template borrows its typed observations through sorting with the standard library’s Cow; decoded rows are owned. The controller keeps the bound envelope by taking ownership of those rows, without reparsing its audit bytes. The scanner consumes the same model without rebuilding its fields or encoding and parsing each row again. Sealed scanner intake consumes the typed controls request, retaining its envelope storage instead of encoding and reparsing it. A counting writer preserves the exact encoded-byte ceiling for in-process inputs too; it does not retain a JSON buffer. Compiled consumers additionally check producer versions, family membership, and semantic laws such as valid routes or sorted record keys. Parsing an envelope never turns it into a pass, a block, or a suppression.

Envelope construction returns the typed model; canonical encoding belongs to intake and output boundaries. Template intake checks the bound envelope’s byte ceiling through a sink, without retaining a discarded output buffer. Controller audit capture and site-output producers write the canonical bytes they actually retain. The canonicalization library may still allocate internally; this separation does not promise allocation-free serialization.

This contract authenticates nothing by itself. Provider-enforced use must acquire it outside the repository. Each sealed value carries an independently planned expected context digest; the engine requires the producer’s context digest to match before interpreting any observation. A repository file, cache entry, or self-asserted local producer cannot promote its own observations to authority. Partial evidence may prove a fact positively only where a later kind contract permits it; absence can carry meaning only for a declared complete set over the exact input digest.

The first compiled consumer accepts one complete sphinx-inventory-set producer at version 1, with no source-report binding. A sphinx-label observation carries an inventory identity, a Docutils-normalized label, and one syntactically valid absolute HTTP(S) destination. The engine uses that table only after every envelope in the controls request matches the exact candidate identity. One unique prefixless :ref: label resolves through the inventory; repeated labels across inventories remain ambiguous, colon-prefixed names remain unsupported, and local declarations keep precedence. Missing evidence, an incomplete producer, another producer version, a stale candidate binding, or an invalid observation can never clear a missing label.

The second compiled consumer accepts at most one complete site-build producer at version 0.5.1. A site-route observation carries one exact absolute-path URI, one repository source document, and a byte-sorted unique set of decoded anchor identities. Routes exclude authority, query, and fragment components; sources obey the repository-path grammar; anchors and their aggregate count are bounded. On the candidate side only, an exact route resolves to its scanned structured source. A nonempty fragment first matches a published id or legacy <a name> anchor verbatim, then by percent-decoded identity; ASCII-case-insensitive top identifies the page top. This preserves a literal percent sign in a valid anchor without making malformed escapes resolve by guesswork. A site-generated-route carries the same route and anchors plus a required nullable source. A repository path is attribution for generated output, not its target body, and must remain an exact ordinary candidate blob. null says the completed page has no repository attribution; it does not invent a virtual source. Either form resolves as external/site-build. A missing source field or malformed attribution rejects the complete evidence. Query text remains identity data. A route absent from the evidence, an absent anchor, an unsuitable attributed source, and image use remain unsupported rather than being guessed into either a pass or a failure. A site-redirect observation maps one exact redirect route and its repository routing source to its exact terminal route, not an intermediate hop. The destination may carry a fragment but no query. It resolves only when that terminal route has one uniquely claimed source-backed or generated page and the effective fragment is in its anchor set. Following the HTTP Location rule, an absent destination fragment inherits the authored fragment, a nonempty one replaces it, and an empty # suppresses inheritance. Self-redirects and fragments containing raw control characters make the evidence invalid. Conflicting route owners and redirects ending at a missing, ambiguous, nonterminal, or anchor-less target do not resolve and each produce one site-build-defect whose fact retains the exact route, claim identity, reason, and every available routing source. A conflict containing only unattributed generated pages has an empty source set and no location path rather than a fabricated one. A site-navigation observation adds one source root, its navigation manifest, rendered entrypoint routes, and the byte-sorted unique source set reachable through the completed link graph; that set may be empty. Every entrypoint must be a unique page route, every reachable source must own a repository-backed route, and all named sources must remain beneath the declared root. Only then does unlinked-document mean a scanned structured source inside that root which is neither the manifest nor reachable. Without this observation the engine makes no navigation claim. The base side never consumes candidate build output.

The public check command may read one candidate-independent semantic template. It has the producer, completeness, and observation fields above but no subject field. After the scanner resolves the exact commit tree or pins the staged-index projection, it binds the template to that candidate and passes the resulting envelope through the same compiled consumers. The file is repository-user-selected and its context is not independently planned, so the report remains self-asserted; this local convenience path cannot become provider authority.

The offline amiss record-set authoring form accepts one closed normalized record-set input and emits that template shape with the fixed record-set@1 producer contract. Its rows pass the same record validation the scanner uses: keys are sorted and unique, and keys and display values are nonempty, control-free, and bounded. The specialist still owns extraction, its stable identity, both supplied digests, and whether the set is complete. Amiss neither executes a language tool nor recomputes or authenticates those claims; the command only validates and canonicalizes their transport. Its output therefore remains self-asserted when supplied to check.

The separate unpublished amiss-rust-public-api producer is one such specialist, but it writes the checked template directly so the same bytes can be a planned workflow artifact. It accepts exactly one bounded producer context and one bounded Rustdoc JSON file:

amiss-rust-public-api --context rust-public-api-context.json \
  --rustdoc target/doc/example.json > amiss/semantic-template.json

The producer currently consumes format 61. The example was measured with nightly-2026-08-28; the workspace’s pinned Rustdoc emits format 60 and is deliberately refused. Generate the input with the exact separately pinned toolchain named by the producer context, for example:

cargo +nightly-2026-08-28 rustdoc -p example --lib -- \
  -Z unstable-options --output-format json

Do not edit the format number in an artifact.

The context uses Serde JSON with this closed shape:

{
  "cfg": [],
  "compiler": "rustc 1.100.0-nightly (e457a7b0d 2026-08-27)",
  "dependencies_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
  "features": ["default"],
  "name": "rust/example/local-function-declarations",
  "package": "example",
  "rustdoc_format": 61,
  "schema": "amiss/rust-public-api-context",
  "target": "example",
  "target_triple": "x86_64-unknown-linux-gnu"
}

The feature and cfg sets are byte-sorted and unique. The set name ends in /local-function-declarations, so its completeness cannot be mistaken for the entire Rust item surface or dependency-owned re-exports. Compiler, package, target, features, cfg, and the operator-computed dependency/configuration digest all enter the context digest. target is the Rust crate target name recorded on the root module, after Cargo’s crate-name normalization or any explicit target rename; it and the target triple are checked exactly against the parsed input. Package and crate target names are intentionally separate because Cargo permits them to differ. Rustdoc carries no Cargo package identity, so package is context-bound while the independently declared target is the artifact-side check. The active producer accepts only the one Rustdoc format represented by its pinned maintained adapter. It does not start Cargo, rustdoc, or another process. Completeness applies only to that exact feature, cfg, target, and dependency context. Two configurations are independent record sets and need distinct names when supplied to one scan; neither the producer nor the scanner silently unions or intersects them. A matrix-wide API requires a separately named producer contract that declares union or intersection and resolves keys whose values differ between configurations.

The complete set contains public free functions defined by the root crate, public functions from inherent implementations of its public structs, enums, and unions, and functions declared by its public traits. Keys use the disjoint fn/, inherent-fn/, and trait-fn/ namespaces followed by the adapter-owned public import path; an associated-function path appends its method name to its owner path. A local re-export therefore owns its alias rather than the definition’s private path. Trait-implementation bodies and functions defined by a dependency are outside this explicitly scoped set; those need separate impl-relation or dependency inputs before they can be called complete. Specialized inherent implementations that expose the same public owner and function name are refused as ambiguous; neither Rustdoc numeric IDs nor rendered syntax are promoted into a false stable identity.

Each value is a one-line canonical comparison string made from the adapter signature and that exact path. It is not Rust source or downstream call syntax. Rustdoc removes raw-identifier markers from canonical paths, and the adapter may retain crate-relative type paths and crate-authored parameter names. ASCII whitespace is collapsed so ordinary multiline where predicates remain representable. Malformed input, an unsupported format, a crate-target or target-triple mismatch, an ambiguous path, a duplicate row, more than 100,000 rows, a context above 64 KiB, or Rustdoc JSON above 32 MiB refuses the output instead of weakening completeness. Raw Rustdoc numeric IDs appear in neither keys nor values.

The scanner needs no Rust-specific control to attach one of those values to visible documentation. This projection assertion selects the producer row for example::check:

{
  "document": "docs/api.md",
  "name": "check-signature",
  "projection": "code-text-v1",
  "sink": "previous-code",
  "source": {
    "kind": "record-value",
    "set": "rust/example/local-function-declarations",
    "key": "fn/example::check"
  }
}

The named document owns the ordinary projection sink immediately after the visible value:

```text
pub fn example::check() -> bool
```
[amiss:check-signature]: <amiss:projection>

The set is the exact context name and the key is a producer-owned stable record identity. A changed declaration becomes projection-drift; a missing row is proven absent only when the producer says this scoped set is complete. To show the whole scoped API instead, use a record-set source with the same set and sorted-rows-v1. That projection compares every display value in byte-sorted order and refuses a partial set before judging visible rows. These are the same generic projection controls used by any record producer; the scanner does not parse Rust syntax or introduce a symbol-specific finding.

The sealed controls request remains the provider-authenticated intake. It decodes each supplied envelope into the same closed model: malformed shapes are invalid controls requests, while digest, context, ordering, and semantic checks remain the consumers’ responsibility. The request schema includes the full envelope definitions with local references, so it needs no external schema registry. A controller plan may hold candidate-independent templates such as an Intersphinx inventory set. A trusted acquisition may instead return exact candidate-independent template bytes beside the repository and action roots. The frozen plan names each acquisition identity, producer kind, producer identity, version, and expected context exactly once. While building the sealed job, the controller strictly parses both sets, requires every acquired template to match its planned identity and producer context, binds the exact candidate and no source report itself, applies their one combined count limit, orders them by payload digest, and rejects collisions. The engine repeats the context comparison from the sealed pair before consuming the evidence. A missing, extra, malformed, stale, wrong-context, duplicate, or oversized acquired set is runtime tampering, not absent evidence.

The same plan contract can freeze each provider workflow artifact as an acquisition source. Its provider and repository, workflow identity, event, artifact name, sole payload member, producer contract, and separate archive and payload byte ceilings are all plan-digest inputs. The only candidate rule is exact equality with the authenticated provider run’s candidate commit; it is not repository-selectable policy. This is a controller trust primitive, not a claim that every provider can fetch such artifacts: a lane must separately expose operator configuration and implement the provider-specific authenticated acquisition before the expectation is usable.

Job construction also produces one canonical semantic-input audit value, capped by SEMANTIC_INPUT_ARTIFACT_BYTES before base64 allocation. In payload-digest order it holds each exact template byte stream, the exact canonical bound envelope, its acquisition identity when acquired, and SHA-256 and semantic payload digests. Reconstructing candidate binding therefore does not depend on mutable producer output. The bootstrap job exposes this value separately from the frozen engine report.

Provider lanes retain that value beside an accepted report and publish its digest and authenticated locator, so audit does not depend on a producer workflow’s shorter artifact lifetime.

A successful report projects the accepted envelopes’ payload and producer/input identities under controls.semantic_evidence; the sealed bootstrap checks that projection against the request. An inventory-backed external destination is already resolved evidence, so the external-probe plan does not schedule it for a second network judgment.

Provider services can produce that set from controller-configured local objects.inv files. The producer bounds both compressed and decoded bytes before the pinned sphinx_inv parser sees the body, selects only std:label records, resolves their locations beneath an operator-owned HTTP(S) base under the engine’s URI grammar, and binds the exact inventory bytes, identity, and base into its input digest. The resulting template is held once in the controller plan and receives the exact candidate identity only while the sealed job is built. Fetching and caching remain deployment concerns outside both the engine and the repository being checked; provider configuration accepts only the bounded local result.

The controller’s first site-build producer consumes an operator-owned identity containing the exact repository book.toml path, publication prefix, optional locale, and optional version, plus the exact post-preprocessor mdBook renderer context from mdBook 0.5.4 and a caller-opened completed HTML output directory. It uses the renderer’s path and source_path, rather than reconstructing routes from SUMMARY.md, so the built page and its original repository source, when one exists, remain distinct after preprocessing. The first rendered chapter’s independent index.html copy is read separately. Every other route follows the HTML renderer’s .html path rule beneath one trusted publication prefix, with URI path segments encoded from the actual output names.

The producer reads only the rendered pages named by the context, with one 16 MiB context ceiling and one 16 MiB aggregate HTML ceiling. A no-follow directory capability bounds every page read. A WHATWG tokenizer extracts decoded id values and hyperlink destinations from the completed HTML. The producer honors the document’s first base URL when usable, retains only links to another proved page under the same publication origin, and walks that bounded graph from the independent index.html entrypoint. It emits the configured source root, its SUMMARY.md manifest, the entrypoint, and the sorted set of reachable repository sources beside the sorted page anchors, then binds the resolved renderer configuration, every exact page digest, and the navigation result into the input digest. A plan independently freezes the site identity; evidence from another configuration path, publication prefix, locale, or version is refused. The wrong mdBook version, no HTML renderer, an escaping or non-text output or source path, a source without an output path, duplicate route ownership, an unreadable page, an unrepresentable anchor or link, or an oversized graph refuses the complete set. A rendered chapter with an output path and no source_path instead becomes an explicitly unattributed generated route. Theme, preprocessor, and configuration effects are therefore observed in their finished bytes without running any of them inside Amiss.

A trusted candidate acquisition may call this producer after an operator-owned build and return the exact candidate-independent template bytes under its planned acquisition identity. The controller, not the producer, binds the candidate. The GitHub provider lane can read an explicitly configured workflow artifact through the App installation API; the Gitea-family and GitLab lanes still expose no such source. The controller neither starts mdBook nor treats repository output or cache state as authority. The acquisition boundary keeps candidate output out of a startup plan and keeps the scanner from searching the repository for evidence.

Site evidence may be complete while its route graph is internally defective. Those defects become ordinary site-build-defect findings: a broken redirect records its source, route, destination, claim digest, and reason, while duplicate ownership records the route plus the sorted distinct claim digests and repository sources. Generated routes have no repository source, so a duplicate route’s source list may be empty even when its claim list is not.

The bound-envelope schema and checked example are scanner-semantic-evidence.schema.json and scanner-semantic-evidence.json. The candidate-free input has its own scanner-semantic-template.schema.json and scanner-semantic-template.json. The record-set authoring input is checked by scanner-record-set-input.schema.json and its scanner-record-set-input.json example is required to reproduce the semantic-template example through the real writer. The engine still executes no producer and treats no repository-controlled evidence as authority.

Last change: , commit: bdc81786

Publication audits

A repository scan proves facts about one repository candidate. It does not prove that a completed site was deployed, that a public channel serves that site, or that the site describes the intended product release. Those are separate publication facts acquired after the scan and often after a deployment.

The publication plan is the operator-owned half of that later audit. It binds one intended relation between:

  • the payload digest of the scanner report the operator accepted;
  • the docs repository, commit, tree, and full scanner candidate identity;
  • the deployment provider instance, environment, channel, and canonical HTTPS URL;
  • the immutable completed-site artifact and the site producer input digest derived from it;
  • the exact product resource URI and digest;
  • the independently selected deployment evidence producer and its context; and
  • the operator’s named docs-to-product relation rule and its context digest.

Channel names such as stable, latest, or next are opaque policy labels. Amiss does not sort versions, compare timestamps, select a tag, or infer that two similarly named resources belong together. The product and site URI spellings are identifiers; their SHA-256 digests establish the exact bytes. A mutable URL or tag without its resource digest is not a publication identity.

The plan is a closed, 64 KiB, digest-bound JSON document. Git object IDs must match their declared object format. The canonical public URL is HTTPS without a query or fragment. Resource URIs use one exact lowercase absolute scheme and carry no fragment. Provider, channel, environment, producer, and relation identities use the bounded artifact-identity grammar. Unknown fields, invalid values, mixed Git formats, oversized input, or a changed payload refuse the whole document.

The matching publication evidence is a provider-normalized receipt for one successful terminal deployment. It binds the exact plan payload digest and independently repeats the observed docs candidate, target, completed-site artifact, and product resource. It also records:

  • the selected evidence producer identity, version, and context digest;
  • the provider deployment record as an immutable URI-and-digest resource;
  • the exact workflow or deployment definition as another immutable resource; and
  • the one-based provider run attempt that distinguishes reruns.

Only succeeded is a receipt outcome. A failed, cancelled, pending, partial, or unauthenticated provider response cannot be encoded as successful publication evidence. Repeating the publication facts is intentional: the later offline assessment compares independently acquired facts with the plan rather than trusting a producer that merely echoes a plan digest. The provider adapter must authenticate its API, attestation, or receipt before normalization; engine crates perform no network or signature work.

The offline assessment has three outcomes. matched means the bound receipt came from the planned producer context and its docs, target, site, and product facts all equal the plan. refuted means that trusted receipt disagrees with at least one of those four fact groups, each named in a sorted reason set. unproven means there was no receipt, it answered another plan, or it came from another producer context. Foreign or absent evidence can never refute a plan.

The assessment binds the accepted report, plan, optional evidence, and exact evaluator binary by digest. A missing receipt is represented by a null evidence digest and the single evidence-absent reason. Malformed, failed, pending, partial, or mutable-only provider material never becomes a typed successful receipt; callers retain that acquisition failure and assess the plan as unproven instead of manufacturing a negative fact.

The outer payload digests are integrity checks, not signatures or authority claims. Repository content must not choose the plan, producer context, relation rule, or credentials. The controller can validate a complete audit before retention: the plan’s report digest and docs repository, commit, tree, and candidate identity must describe the supplied complete scanner report, and the assessment must replay exactly from the supplied plan and optional evidence. Its artifact store can then retain and reopen the exact report, plan, optional evidence, assessment, digest set, and verdict as one immutable evaluation-bound record. The authenticated component routes are described under Retained provider artifacts. No controller lane acquires, stages, or publishes this chain yet, no scanner command consumes it, and a plan alone never says a deployment happened.

The checked public contracts are publication-plan.schema.json, with a matching publication-plan.json example, and publication-evidence.schema.json, with its publication-evidence.json example, and publication-assessment.schema.json, with its replayed publication-assessment.json example. The strict readers, writers, and pure assessment live in amiss-wire; provider-specific payloads never enter the engine contract.

Last change: , commit: 77195531

Locale coverage audits

A repository scan can bind the exact docs candidate that approved a later locale audit, but it cannot infer which pages a documentation generator considers equivalent across locales. Routes are publication outputs; they are not stable page identities. The locale coverage plan therefore names one independently selected inventory producer and treats every page key it will emit as opaque. It can also select the same immutable product-resource identity used by publication audits, without turning a locale version label into a release heuristic.

The plan binds:

  • the accepted scanner report payload digest;
  • the docs repository, commit, tree, and full candidate identity;
  • one site, source locale, target locale, channel, and optional version;
  • an optional exact product resource URI and digest that both locale inventories must identify;
  • the inventory producer identity, version, and plan-owned context digest; and
  • the operator’s coverage-policy identity, context digest, required-page rule, and authorized fallback classes and page scopes, plus whether exact target lineage is required.

Site and channel use the artifact-identity grammar. Locale and version labels use a broader bounded identity grammar so a producer can retain spellings such as de-DE; Amiss compares them exactly. It does not validate BCP 47, order versions, or infer fallback from a locale hierarchy. Source and target locale must differ. The opaque version remains scope metadata and never substitutes for an immutable product resource.

Page selectors have two closed forms. The coverage rule uses one selector: all-source means that every key in the future complete source inventory is required in the target inventory; named carries one nonempty, byte-sorted, duplicate-free set and makes only those source keys required. Other source keys remain optional, while target keys outside the source inventory can still be reported as orphaned. A page key is nonempty, control-free, and at most 4,096 UTF-8 bytes. It is a generator-owned identity such as a canonical docname, never a path or route guessed by Amiss.

The policy also carries a byte-sorted set of fallback rules, unique by opaque class. Each class has an all-source or named page selector describing exactly where that producer-defined fallback mode is allowed. An empty set forbids all fallbacks. The plan chooses authorization; it does not prove that a target page really came from a source resource.

require_target_lineage independently chooses whether every observed target-owned page must carry exact source lineage. False preserves a coverage-and-fallback-only audit and ignores any supplied target lineage. True makes missing lineage unproven and a mismatched lineage digest an exact refutation. Fallback pages are excluded because their F02 origin already binds the exact current source resource.

The optional plan product reuses publication’s PublicationResource directly: one absolute URI identifies the resource and its SHA-256 digest identifies the exact bytes. Null selects no product alignment policy. Amiss does not derive this value from the channel, scope version, tag, timestamp, or similarly spelled URL.

A locale audit reads one snapshot. The pull request that edits a source page and not its translation is caught earlier, by the translations pairs in the repository policy, which make the scan warn with translation-drift at the page left behind; Controls and policy has the key.

The contract intentionally contains no translation verdict or timestamp. A target page with the same key can prove structural coverage only. Exact lineage can prove which normalized source resource a target was based on; it cannot prove that the page was translated correctly, remains semantically equivalent, or required a change. This contract uses a digest because the source inventory supplies an exactly comparable digest. It does not accept an opaque revision without a matching source-side revision identity.

The plan is a closed, 64 KiB, digest-bound JSON document. Unknown fields, malformed identities, mixed Git object formats, repeated or unsorted named keys, oversized text, or a changed payload refuse the whole document. The outer digest establishes integrity, not authority: repository content must not choose the producer context or operator policy.

The matching evidence contract repeats the plan digest, docs candidate, locale scope, and producer context, then carries separate source and target inventories. Each inventory has its own input digest, nullable independently observed product resource, completeness bit, and byte-sorted map from page key to exact resource digest. The producer context defines which normalized bytes those digests identify. A null product says the authenticated producer could not establish that side’s release identity; it does not assert a mismatch.

Every observed target page also carries one closed origin. target-resource says the producer observed a target-owned resource and carries either its exact based_on_source_digest or null when the producer has no exact lineage assertion. fallback names an opaque producer-declared class and the exact source resource digest from which the fallback was obtained. A separate fallback list would make omission indistinguishable from target ownership. Requiring an origin on every target instead forces one explicit producer claim; authority still comes from authenticated acquisition outside the engine.

Completeness belongs to each side independently. A false value preserves the pages the producer did observe, but absence from that inventory is not evidence that a page is absent from the locale. Product availability is independent of page completeness: a complete page set may still have an unproven product identity, and a partial page set may carry an exact product receipt. The two inventories may carry at most 100,000 page rows combined inside one 16 MiB document. Page keys are unique within each side; malformed digests, duplicate or unsorted keys, unknown fields, and a changed payload refuse the whole receipt.

The engine reader establishes shape and integrity, not producer authority. Repeating a plan digest does not establish that the independently repeated facts match that plan. The pure offline assessment first requires the exact plan digest and selected producer. Foreign evidence remains unproven; correctly bound docs or scope disagreement refutes the plan without comparing the foreign inventories. Evidence acquisition outside the engine must therefore authenticate the selected producer, while the selected producer context defines the meaning of its origin classification.

For matching facts, every page row in the assessment is proved by presence on one side and complete absence on the other. A complete target can therefore prove that an observed required source page is missing even when the source inventory is partial. A complete source can likewise prove that an observed target page is orphaned when the target inventory is partial. Such a refutation is valid, but coverage.complete: false says the reported rows may be only a lower bound. The assessment matches only when its policy-scoped missing, orphan, named-source, and fallback checks are exhaustive and clean. A named policy can be exhaustive without an unrelated full source inventory when every named requirement and every target key has explicit source presence; all-source always requires a complete source set. Page resource digest differences remain deliberately inert.

Every fallback assessment row retains its page key and class. allowed means one plan rule admits that class and page and the declared source digest equals the observed source resource. unauthorized and source-mismatch are exact refutations. If the source page is absent from a partial source inventory, source-unproven keeps the whole result unproven; absence cannot become a digest mismatch until the source inventory is complete. Allowed fallback is not a translation or freshness verdict: it proves only the exact policy and provenance relation named by the contracts.

When target lineage is required, the assessment also checks every observed target-owned page whose source row is available, including pages outside a named required-coverage set. current means its declared based-on digest equals that current source resource; stale is the exact unequal case; unproven means the producer supplied no exact based-on digest. A target absent from a complete source set is already orphaned. A target absent from a partial source set remains covered by the existing source-incomplete result, so the evaluator does not manufacture a lineage row without a current source value to compare.

When the plan selects a product, the assessment compares each inventory’s product independently with that exact planned URI and digest. The source and target fields reuse the ordinary matched/refuted/unproven verdict vocabulary. Matched means exact equality; refuted means a different immutable resource was observed; unproven means that inventory supplied null. A product mismatch is an exact refutation even if the other side is unavailable. If the plan product is null, both supplied product receipts are deliberately ignored and the assessment product result is null. This relation proves release identity only, not translation quality or deployment success; the producer or future publication lane must authenticate how each inventory acquired it.

The assessment binds the exact evaluator, accepted report, plan, and optional evidence payload. Its three byte-sorted key sets name policy keys absent from source, required source keys absent from target, and target keys absent from source. A fourth byte-sorted set records every assessed fallback, and a fifth records every assessed target lineage. The document is bounded to 16 MiB and 200,000 rows across those sets. A separate nullable product result records the source and target resource verdicts. coverage.complete remains strictly about the page comparison, so the overall assessment can be unproven solely because a product receipt is missing while coverage is complete. Missing, unbound, wrong-producer, or otherwise insufficient evidence is unproven rather than clean. amiss locale-assess --plan <path> --evidence <path> judges one pair offline and writes the assessment; see the invocation chapter. The controller validates a complete report-bound chain before retention and stores its four components under the record’s own ceilings, 64 KiB for the plan and 16 MiB for the evidence and assessment.

amiss locale-inventory stages that evidence from the candidate tree. Translated documentation is laid out three ways, not one way per generator: a directory per locale, which Docusaurus, VitePress, Starlight and Hugo use; the locale in the filename, which Hugo and the MkDocs i18n plugin use; and gettext catalogs, which Sphinx and the mdBook helpers use. The first two are one walk with two key rules and are what this verb reads. Gettext has no reader yet, and a reader for it would report message coverage rather than page presence.

The page key is the path under the locale’s own root, which is not a spelling Amiss invents. A Docusaurus doc id is the path under the docs folder without its extension, a Sphinx docname is the path under the source directory, an MkDocs src_uri is the path under docs_dir, and Hugo and Starlight both define a translation as the file at the matching relative path. Taking that key from the tree needs no build and no generator configuration, which keeps executable build semantics out of the trust boundary. It also cannot see the two escapes that move a page’s identity off its path, a Docusaurus id and a Hugo translationKey; a page using either is reported as a missing and orphaned pair. A translation written in another page suffix than its source, a .md for an .mdx, is the same page, since both generators pair them by the path without its suffix, so a target page no source page shares a key with takes the one source key sharing its stem.

The context names each side’s root, the locale it claims, an optional filename suffix, and the file suffixes that count as pages. The plan names the object format, commit, and tree, so the verb walks exactly that snapshot or refuses. A resource digest names the blob a path resolves to, so coverage reads no file content and equal digests mean byte-identical files at that commit.

That last property carries the useful case. A target page whose blob is still the source’s blob is not a translation, it is the source sitting in a translation slot, so it goes out as a source-identical fallback rather than a target resource. The Docusaurus i18n tutorial tells operators to copy the whole default-locale tree and translate afterwards, so an inventory that compared page keys alone would call an untranslated copy a clean pass; an empty fallback set in the plan refuses it instead, and a plan that authorizes the class accepts it deliberately. Pages the walk cannot inventory, a symlink or a name outside UTF-8, drop their side’s completeness bit rather than disappearing. Neither side carries a product receipt: a tree records none.

Target lineage is the one thing a translated page can say about itself. MDN records the English commit each translation was made from in its front matter, and 21,757 of its translated pages carry it:

---
title: Array
l10n:
  sourceCommit: 5b20f5f4265f988f80f513db0e4b35c7e0cd70dc
---

A context naming that key, "lineage": ["l10n", "sourceCommit"], has the verb read it from every target page, look up the source page at that commit, and digest those bytes the way a current page is digested. So the assessment’s lineage rows say which translations were made from the source as it stands, current, and which from a source that has moved on since, stale. The key is one name or a name indented under one parent, and the value is a full commit ID in the plan’s object format. A page naming no commit, or one the object store does not hold, as in a shallow clone, is unproven rather than current. Without the key the verb reads no file content and every target lineage stays unproven.

Two contexts cover nearly every repository. A site that gives each locale its own directory names both roots, and leaves the suffix null. This is the Docusaurus shape, where the German tree sits under its own plugin path:

{
  "source": { "root": "docs", "locale": "en", "suffix": null },
  "target": {
    "root": "i18n/de-DE/docusaurus-plugin-content-docs/current",
    "locale": "de-DE",
    "suffix": null
  },
  "documents": [".md", ".mdx"]
}

docs/guide/start.md and i18n/de-DE/docusaurus-plugin-content-docs/current/guide/start.md both key as guide/start.md. VitePress, Starlight and Hugo’s directory mode differ only in the two roots: docs beside docs/de-DE, src/content/docs/en beside src/content/docs/de-DE, content/en beside content/de-DE.

A site that marks the locale in the filename gives both sides the same root and names the token. This is the MkDocs i18n default and Hugo’s filename mode:

{
  "source": { "root": "docs", "locale": "en", "suffix": null },
  "target": { "root": "docs", "locale": "de-DE", "suffix": "de-DE" },
  "documents": [".md"]
}

Here docs/guide/start.md and docs/guide/start.de-DE.md both key as guide/start.md. A null suffix means the filename carries no locale token at all, so start.de-DE.md is not an English page and start.md is not a German one. That reading holds only where both sides share a root; under a locale directory a dotted name such as v1.2-notes.md is a page like any other. When one root sits inside the other the deeper one wins, which is what lets a locale directory live under the source root.

A site that keeps its source locale at the content root, which is Starlight’s root locale, holds every other locale’s directory under the source root, and none of those pages is a source page. excluded names their roots, byte-sorted, and a page under one belongs to neither side:

{
  "source": { "root": "src/content/docs", "locale": "en", "suffix": null },
  "target": { "root": "src/content/docs/de", "locale": "de-DE", "suffix": null },
  "documents": [".md", ".mdx"],
  "excluded": ["src/content/docs/fr", "src/content/docs/ja"]
}

Without it the French and Japanese pages count as English ones, and German is short every page they hold.

documents is byte-sorted, and every entry starts with a dot. A file whose name ends in none of them is not a page, so images, partials, and configuration never turn into missing translations.

Running it takes three commands. amiss locale-plan writes the plan from the accepted report and the layout, so nothing is sealed by hand: the report fixes the docs candidate, its repository, and its identity digest, the layout fixes both locales and the producer, and the flags fix the scope and the policy. The report has to come from a commit-pair check that named its repository, since the plan binds a commit and a repository:

amiss locale-plan --report amiss-report.json --context locales.json \
  --site widget-docs --channel stable --fallback source-identical --format json > coverage-plan.json
amiss locale-inventory --repo . --plan coverage-plan.json --context locales.json \
  --format json > coverage-evidence.json
amiss locale-assess --plan coverage-plan.json --evidence coverage-evidence.json

A written plan requires every source page, allows the one --fallback class it names on every page, and requires target lineage only with --require-lineage. Its policy identity is amiss-locale-plan, with the context digest taken over those rules. A policy naming only some pages, or several fallback classes, is still written by hand. Human output from the inventory ends with the producer line the plan named:

amiss locale-inventory: en 42 pages complete
amiss locale-inventory: de-DE 39 pages complete
target pages still carrying the source bytes: 7
producer amiss-locale-tree 1.2.0 context sha256:0f3c...

Those seven pages are the point of the audit. Either the plan authorizes source-identical for them, or the coverage stays refuted until somebody translates them.

The checked public contracts are locale-coverage-plan.schema.json, with the matching locale-coverage-plan.json example, and locale-coverage-evidence.schema.json, with its locale-coverage-evidence.json example, and locale-coverage-assessment.schema.json, with its replayable locale-coverage-assessment.json example. Their strict readers and writers live in amiss-wire; generator-specific parsing and authentication stay outside the engine crates.

Last change: , commit: 60aacafb

Limits and refusals

The report has a closed set of named resource ceilings. Crossing a snapshot ceiling produces a typed error carrying the wire resource name, configured limit, and observed lower bound, and a run that cannot complete exits 2. A per-document ceiling costs that one document instead, as the refusal rules further down set out. The table is rendered from the Rust defaults and checked in CI, so a default cannot change without updating this page.

These are accounting ceilings, not all wall-clock deadlines. Document bytes are charged before parsing, parser node and nesting totals after the grammar returns, and embedded-code evaluation bytes inside the parse itself, at every candidate close of an MDX code region. How the ceilings relate to CPU, and which lanes carry wall-clock watchdogs, is described in Security model.

Line-fragment work is charged pessimistically: the complete target size, once per distinct target identity (path, file mode, and object id) and numeric range. Successful and out-of-range results are cached, so repeated identical anchors do not multiply the charge. A changed object or mode at the same path is charged again. A range the budget cannot afford is not judged: it stays unsupported rather than ending the run, the way a heading anchor does below. A value claim is the exception, since it has no unsupported outcome, so a claim past the budget still crosses it.

One referenced target past referenced-target-blob-bytes costs that target and not the run. Its path resolves from the tree without its bytes being read, its evidence digests are taken from its Git object id under their own domain, which moves exactly when the bytes do, and a fragment into it stays unsupported. A link to a demo video or a large generated file therefore resolves on every commit, and deleting the file is still a missing target. A value claim reads its target line, so a claim on such a file still crosses the ceiling.

Heading-anchor work is charged the same way and by the same rule, once per target identity rather than per anchor, because the identities every known renderer would publish are built in one parse of the target and then answered from memory. This is the only place the engine parses a file it did not discover as a document. A target the budget cannot afford is not judged: its anchors stay unsupported rather than becoming missing.

Projection work has separate snapshot totals for admitted assertions, selected source bytes, records prepared for comparison, canonical projected bytes, and diagnostic preview bytes. Repeated assertions spend these totals even when their source blob is cached: caching avoids a second Git read, but it does not make the projection comparison free. Preview bytes are charged before they are copied into a finding; rows omitted by the fixed preview bound cost no report memory and are represented by their exact omitted count.

Report resourceLimit
git-object-bytes134,217,728
git-compressed-object-bytes268,435,456
aggregate-git-compressed-object-bytes-per-evaluation2,147,483,648
git-pack-directory-entries8,192
git-pack-files4,096
git-pack-index-bytes536,870,912
aggregate-git-pack-index-bytes1,073,741,824
git-delta-depth128
git-index-bytes268,435,456
git-tree-entries-per-snapshot1,000,000
documents-per-snapshot100,000
control-input-bytes16,777,216
selected-control-blob-bytes16,777,216
aggregate-selected-control-bytes-per-snapshot67,108,864
repository-policy-entries100,000
debt-items100,000
waiver-items100,000
raw-path-bytes4,096
document-blob-bytes4,194,304
referenced-target-blob-bytes16,777,216
aggregate-referenced-target-bytes-per-snapshot536,870,912
ignore-declaration-blob-bytes1,048,576
aggregate-ignore-declaration-bytes-per-snapshot16,777,216
aggregate-line-fragment-evaluation-bytes-per-snapshot536,870,912
aggregate-heading-anchor-evaluation-bytes-per-snapshot536,870,912
projection-assertions-per-snapshot10,000
aggregate-projection-selected-bytes-per-snapshot67,108,864
projection-records-compared-per-snapshot200,000
aggregate-projection-projected-bytes-per-snapshot67,108,864
aggregate-projection-preview-bytes-per-snapshot16,777,216
aggregate-document-bytes-per-snapshot83,886,080
raw-link-destination-bytes16,384
parser-nesting256
parser-nodes-per-document250,000
parser-nodes-per-snapshot5,000,000
aggregate-embedded-code-evaluation-bytes-per-snapshot536,870,912
references-per-document16,384
references-per-snapshot1,000,000
declared-labels-per-snapshot1,000,000
organization-policy-entries100,000
complete-findings100,000
typed-analysis-errors-retained64
machine-json-bytes268,435,456
private-temporary-storage-bytes67,108,864
evaluator-managed-memory-bytes1,073,741,824

Three of these ceilings have been measured against real repositories rather than reasoned about. references-per-document was 4,096 until fastapi’s release notes came in at 7,075 references in one auto-generated changelog; the next largest documents measured anywhere are just’s and helix’s changelogs, at about 2,900 and still growing. machine-json-bytes moved for the same reason and against the same repositories. A 64 MiB reservation refused both of the largest documentation sets measured: fastapi serializes 1,664 documents and 15,334 references to 78 MB, and Docusaurus’s own repository 1,543 documents and 23,035 references to 111 MB. At 256 MiB both pass, and the value stays a quarter of the memory ceiling bounding the same process.

Raising it treated a symptom whose cause has since been removed. Ninety-one percent of fastapi’s report was one finding per external URL, each carrying a verbatim copy of an observation row the report already held and already named by id. An external reference is now an observation and nothing else. Declined references went the same way later: a row per declined reference, each with its own copy of that reference’s observation, was 58% of MDN’s content report, and those rows now fold to one per document and kind. fastapi serializes 29 MB and Docusaurus 41 MB, both of which would have fitted the old reservation. What the reservation buys now is headroom rather than admission. complete-findings allows 100,000 findings, and only the leanest finding this engine builds fits a hundred thousand times under the reservation. A broken-link finding carries the evidence of its occurrences and weighs several kilobytes, so a flood of those meets the reservation first, at some tens of thousands of findings, and ends as OUTPUT_LIMIT_EXCEEDED. Either ceiling ends the run incomplete, and neither ever ships a findings array cut short.

aggregate-document-bytes-per-snapshot is the third, and it was held at 33,554,432 by an ordering bug rather than by a measurement. The report used to be spelled into memory before its size was checked, so a document set whose report would overrun machine-json-bytes died in the allocator instead of naming the ceiling it crossed, and the document total was the only counter that fired early enough to prevent that. The size check now counts the report as it is written to a sink, so an overrun ends the run at exit 2 with OUTPUT_LIMIT_EXCEEDED and the document budget is free to answer its own question. At 83,886,080 the Kubernetes website scans: 8,232 documents and 70,743,496 bytes of them, 68,408 references, a 139 MB report, and a 519 MB peak against the 1 GiB address space. MDN’s content repository scans too, 14,649 documents in a 182 MB report at a 631 MB peak. Before the fold it crossed the report ceiling at 333,847,393 bytes.

The last two rows are sandbox-descriptor values rather than ordinary scanner counters. The CLI applies the managed-memory value as an address-space limit on Unix; the current public lane does not independently verify that limit on every platform or establish a provider-enforced temporary-storage sandbox. Reports therefore label this assurance self-asserted, as described in Project status. A process-level breach may prevent a report rather than produce RESOURCE_LIMIT_EXCEEDED.

For measured counters, 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 run cannot be trusted, no complete pass is produced. The machine report records the refusal and exit class 2. A base commit the store does not hold, a tracked file whose object is missing, an index with an unresolved merge conflict, a name outside the path grammar, a control file with a duplicated JSON key: each has a named error code (GIT_OBJECT_MISSING, 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.

One file is not the run. A document whose bytes will not decode as its format requires, one whose markup its grammar rejects, and one that crosses one of the four per-document ceilings say nothing about the rest of the tree, so none of them ends the run. Such a document is unsupported: it is counted in the summary, named in the human output, and its report row carries undecodable-document, unparsable-document or resource-ceiling-crossed. An unsupported-document-format finding records that its references went unchecked. What the old refusal protected still holds. The file is never quietly skipped, its references are never counted as checked, and a repository policy that protects that path fails the run through coverage-reduced.

The four per-document ceilings are document-blob-bytes, parser-nesting, parser-nodes-per-document, and references-per-document. One reference is smaller still: a destination past raw-link-destination-bytes, such as an inline base64 image, costs that reference alone, which is declined as oversized-destination while the rest of its document is read and checked. Every other ceiling on this page bounds a whole snapshot or the run itself, so crossing one is a refusal.

The codes themselves live on Analysis errors, one fixed sentence each saying what happened and what to do about it. Two of them are this page’s own: RESOURCE_LIMIT_EXCEEDED for a crossing and OUTPUT_LIMIT_EXCEEDED for a report that would not fit.

Last change: , commit: 08dcc3d4

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 the emitted report cannot be mistaken for a complete pass. Process creation belongs to the separate amiss-bootstrap executable; it is not a capability exposed by the scanner engine.

The engine has no network acquisition interface and does not fetch missing objects. It never writes to the repository, which the no-write tests check both by comparing the tree and by scanning a read-only repository. 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 receive fuzz targets and pinned conformance corpora. Document byte admission is charged before parsing. Parser node and nesting totals, however, are measured and charged only after the grammar returns; they are output budgets, not a general CPU deadline inside the parser. The order is explicit in the scan pipeline. One budget does act inside the parse: every candidate close of an MDX code region charges the accumulated region against the aggregate-embedded-code-evaluation-bytes-per-snapshot ceiling before the lexical scan reads it, which bounds one measured quadratic case. The history of that case is in the corpus notes. Two more are measured and have no budget inside the parse, both in grammars this engine embeds rather than writes: a run of unclosed JSX tags on one MDX line, about 21 s for 150 KB under markdown-rs, and a *a_ run on one Markdown line, about 41 s for 1.2 MB under pulldown-cmark. The AsciiDoc scanner is this engine’s own and reads each line in one forward pass.

A Markdown parser panic is caught and converted to PARSER_PANIC against the document that caused it instead of aborting the process. The known panic fixtures live in the conformance corpus and tests pin that classification. This protects the run from that failure mode; it does not turn the post-parse node limits into a wall-clock guarantee.

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. A path that is raw bytes rather than text renders each such byte as the two-digit \xhh escape of its value, never inventing a character the bytes never encoded, so a byte-named nav\xe9.md and a UTF-8 nav\u00e9.md stay two names. The JSON report keeps fidelity its own way, the exact original string for a UTF-8 path and a bytes object for anything else, because the log needs safety and the report needs fidelity, and those are different channels with different rules. The Action separately HTML-escapes repository-controlled targets before placing them in its Markdown summary and applies GitHub workflow-command escaping to annotation paths and messages.

Two delivery paths need different trust descriptions. The root Action dispatcher makes conventional source release tags usable by delegating to the same version’s immutable action/vX.Y.Z runtime. That immutable second ref is part of a source-tag or source-commit pin; users that require one complete tree can pin the generated runtime tag or commit directly. The runtime is a GitHub convenience event wrapper. It verifies the selected engine’s digest against the release manifest carried in the same action tree, then launches the engine directly. That detects an inconsistent tree, but the manifest is not an independently acquired trust anchor, and this lane does not use bootstrap’s supervisor; it enforces its own wall-clock watchdog, 120 seconds unless the workflow sets the watchdog-seconds input, and a scan that outlives the window ends with no result. The manifest’s build-source host is supplied explicitly and its repository identity is forge-neutral, as pinned by the release validation tests; that prevents a format-level github.com assumption but does not authenticate the supplied identity.

The separately executable amiss-bootstrap implements the stronger local handoff. It bounded-captures a canonical request triplet for commit-pair materialization; requires a complete repository, URL-dialect, candidate-ref, target-ref, and default-ref identity; matches the embedded execution constraint and trusted-time provider/run tuple; verifies that both commit objects were acquired before launch; and validates the action tree and runtime closure. It then starts only the verified engine, in the supplied repository, with a cleared environment and one private argument. A magic value, three bounded lengths, and the exact request bytes travel in evaluation/snapshot/controls order over stdin. Arbitrary engine arguments are not part of this path.

After the run, bootstrap acceptance rejects an unavailable hybrid and binds the engine, profile, commits, candidate and protected target refs, and candidate identity recomputed from the report. It requires exact organization-floor, debt-snapshot, and waiver-bundle presence, digest, and trust source; binds the provider run and trusted instant; and checks the execution-constraint and trusted-time digests against their recomputed semantics. Constraint trust source is bound too. Acceptance also requires the report to retain self-asserted sandbox assurance, local-process enforcement, and no sandbox verification. Clearing the environment, fixing the executable and input, validating runtime closure, and enforcing the 120-second watchdog are meaningful controls; they are not an OCI sandbox or microVM and must not be reported as a provider-verified sandbox. The accepted engine envelope is republished unchanged, so it does not gain an authenticated signature merely by passing through bootstrap.

Provider authentication belongs outside both executables. The controller/ crates keep the raw delivery untrusted until a configured verifier accepts it, keeps provider and storage dependencies out of the scanner, and stops when ownership cannot be proven. Controller delivery defines the neutral record, heartbeat, race, and retry rules.

The source-built provider services are the concrete independent lanes. Their bounded plaintext listeners must sit behind an operator-controlled TLS terminator that also bounds connection concurrency and header, body, idle, and slow-body time. GitHub, Gitea, and Forgejo authenticate the exact webhook body before saving it, acknowledge only durable input, and authenticate the saved bytes again in the worker. GitLab instead authenticates the policy job’s short-lived OIDC token and keeps the request synchronous so only the exact passing result makes that protected job succeed. Each endpoint takes a configured in-process permit before reading the body and holds it through durable admission or synchronous evaluation. That cap does not replace the public connection and slow-client limits at the TLS edge.

Each adapter refreshes the repository, change, commits, trees, and protected merge rule through a controller-owned credential. GitHub requires a strict required check bound to its App and writes the Check Run on the test merge. GitLab requires an enforced merge train and independently owned pipeline execution policy, binds the job’s policy origin and runner, and uses the policy job’s result as evidence. Gitea and Forgejo require one approval restricted to the service’s dedicated reviewer and write the final review through that account. A missing, weakened, bypassable, or changed rule stays fail closed.

All lanes acquire exact SHA-1 repository and action commits through Git protocol v2, with fixed pack, object, inflated-byte, resolved-byte, delta-depth, and indexing-thread limits. They invoke the supervised bootstrap and refresh provider state again before accepting or publishing the result. Closure, changed head or gate, removed authorization, missing output, timeout, runtime tampering, or a wrong identity stays fail closed. The pinned action repository must be on the same provider instance.

The runner independently reopens the acquired repositories and checks the exact commit-tree roots. It derives the sealed job, matches the bootstrap to the pinned execution constraint, clears the child environment and standard streams, retains both bounded output handles, and uses ProcessKit’s cross-platform process-tree boundary. Every terminal path hard-kills and drains the group before output is accepted. Lease loss cancels the same tree. These rules cover ordinary process and ownership races; they do not promise that a host kernel operation can be interrupted if that operation itself never returns.

The provider API credential, webhook key ring or OIDC keys, artifact bearer token, execution constraint, optional controls, bootstrap, TLS terminator, scratch directory, raw inbox where used, delivery ledger, and artifact root are trust roots. Provider and repository administrators who can change the protected merge rule, integration or policy owners, reviewer-account owners, key issuers, and configured bypass actors are also inside the boundary. Repository bytes are not. A deployment is only as independent as its host and those operator-controlled inputs. Self-hosted instances must expose the exact APIs required by their lane and a certificate chain accepted by the Rust TLS clients; there is no insecure-TLS mode.

The inbox and ledger use checksummed ordinary files, not SQL or a database. Their roots must be pre-created private local directories outside the repository and action tree; shared and network filesystems are unsupported. Checksums detect damage, not a malicious local writer. A webhook inbox removes raw bytes after controller completion. The ledger retains running and saved work and keeps GitHub and Gitea-family exact-body completion markers permanently, because those signatures contain no trusted delivery time. A full store rejects new identities instead of evicting accepted work.

The separate artifact store retains exact report and external-assessment bytes only for its configured lifetime. It is bearer-authenticated and bounded by records, total bytes, bytes per evaluation, and expiry. It does not decide replay, freshness, debt, waivers, or acceptance. Corrupt, absent, expired, or full artifact state fails a new publication closed rather than publishing a locator the service cannot honor.

The resulting Check Run, policy-job result, or dedicated review is provider evidence, but the engine report remains an unchanged, self-asserted envelope. The controller neither signs it nor upgrades its sandbox claim. Provider-verified controls gives the exact setup, configuration, limits, storage rules, freshness and retry limits, rotation, and report distinction. No provider update is atomic with the local ledger; each provider page states its reconciliation limit. The GitHub convenience Action still invokes the public scanner directly and does not gain this trust boundary.

Verified consumption

The convenience Action’s manifest check is a coherence check, not a boundary: the manifest and the binaries arrive in the same tag tree, so whoever could rewrite one could rewrite both. The boundary a workflow can hold without operating a provider lane is the release attestation, whose trust root is sigstore and GitHub’s build records rather than anything this repository ships. Every release binary is attested at build, the release workflow verifies its own uploads before finishing, and with immutable releases the tag and its assets are locked at the platform after publication.

To run only what the attestation vouches for:

- env:
    GH_TOKEN: ${{ github.token }}
  run: |
    gh release download v<reviewed-version> --repo HardMax71/amiss --pattern amiss-linux-x86_64
    gh attestation verify amiss-linux-x86_64 --repo HardMax71/amiss \
      --signer-workflow HardMax71/amiss/.github/workflows/release.yml
    chmod +x amiss-linux-x86_64
    ./amiss-linux-x86_64 check --repo . --object-format sha1 \
      --base "$(git rev-parse HEAD^)" --candidate "$(git rev-parse HEAD)" \
      --profile enforce --format json > amiss-report.json

The verification proves the binary came from this repository’s release workflow on GitHub’s runners, byte for byte. It does not authenticate what the workflow around it does: a required check whose inputs and selection an opposing author cannot influence is still the provider lanes’ job. There is no launcher in the action tree; a verifier the artifact itself supplies could never vouch for the artifact, which is why this lane is a recipe over platform primitives rather than a file in the release.

Last change: , commit: 1c025e27

Controls and policy

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

The repository policy is the stronger of the two inputs read from the scanned tree itself, and it is still weak. .amiss/scanner-policy.json can add directories to scan, list protected paths whose removal is always a finding, declare exact source-to-document projections, and raise the disposition of explicit-target-missing, explicit-target-type-mismatch, and invalid-reference. Raise only: repository policy combines with the built-in profile by maximum, so it can promote an observe warning to fail and can never downgrade or suppress 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. That holds for the candidate. A base policy that does not parse was never in force, since every run over that tree ended incomplete, so the change repairing it reads the base as holding none and its base_repository_policy_digest is null.

The other tree-read input is weaker still. .amiss/router.yml names the router that publishes the directory it sits in, for a tree whose generator is configured somewhere else, and it turns on route spellings that resolve a destination against files the tree already holds. Its base line says the URL path that directory is served at, which reads a slash-rooted route as a path under it. It reaches no rule that withholds an answer, so it can move a destination onto a file the tree already holds and can never clear one the tree lacks. Like the policy, a candidate file that says anything else makes the run incomplete with CONFIGURATION_INVALID at that file. What a documentation router serves holds the rules it selects.

Both sides of a comparison are read under the declarations the candidate holds, since the file is tree state and the base of the commit that writes one does not have it. Read each side its own way and the references the declaration moves change identity, so the commit adopting the file is charged with breaking them. A declaration the base held that the candidate does not hold identically is policy-weakened at the file that held it, under router/declaration-removed where the candidate declares nothing there and router/declaration-replaced where it names another router or another base, because dropping one takes its resolutions off both sides at once.

The complete grammar in one example, with projection_assertions optional for compatibility with policies written before projections existed and the file valid only whole:

{
  "schema": "amiss/scanner-policy",
  "document_includes": [
    { "path": "build/docs", "kind": "tree" },
    { "path": "docs", "kind": "tree", "suffix": ".txt", "adapter": "rst" },
    { "path": "notes/ARCHITECTURE", "kind": "document", "adapter": "markdown" }
  ],
  "projection_assertions": [
    {
      "document": "docs/api.md",
      "name": "request-shape",
      "projection": "code-text-v1",
      "sink": "previous-code",
      "source": {
        "kind": "blob-lines",
        "path": "examples/request.json",
        "first_line": 1,
        "last_line": 12
      }
    }
  ],
  "protected_inventory": ["docs/install.md"],
  "finding_dispositions": [
    { "finding_kind": "explicit-target-missing", "disposition": "fail" }
  ],
  "default_branch_aliases": ["refs/heads/master"],
  "anchor_renderers": ["github"],
  "translations": [{ "source": "docs", "target": "docs/de" }]
}

The first tree include readmits a subtree the built-in skip list would drop, which is Discovery’s monorepo lever. The second admits only .txt descendants of docs and reads them as reStructuredText. The document include reads one extensionless file under the markdown grammar. The protected path makes its removal a finding, the disposition row promotes one kind to fail, the alias says the default branch used to be called master, the renderer pin reads Markdown headings the way github.com slugs them, as What thirteen renderers call a heading explains, and the translation pair says docs/de translates docs. The scanner-policy schema closes the grammar, and each array keeps the sort order the schema states. The strictness also sets the upgrade order: an engine that predates a policy field refuses the whole file and leaves the run incomplete, so a repository grows its policy only after every engine reading it has learned the field.

default_branch_aliases is optional and lists old names of the default branch as full refs, sorted, at most 16. A forge serves a renamed branch’s URLs at the new name, so a same-repository URL naming an old name reads as the default branch: it resolves against the tree while the default branch is under test, and stays unread as another version while a feature branch is. The Kubernetes community repository is the case, where 1,102 of 1,192 self-links still say master. Both sides are read under the names the candidate declares, the way router declarations are, and dropping one is policy-weakened under policy/default-branch-alias-removed/<name>.

translations is optional and names translated trees beside their sources, at most 64 pairs sorted by source and then target. A page at a path under source is translated at the same path under target, and a page inside any declared target is never read as a source of its own, so a locale can live under its source’s root. A change then warns with translation-drift at the translation when the source page’s blob changed and the translation’s did not, or when the translation left while its source stayed. The finding is warn in every profile and never blocks. It says a translator has something to look at, not that the translation is wrong. A source page that is new, or a pair removed together, is not drift, and neither is a translation edited alone. Dropping a pair loosens nothing that blocks, so it is not policy-weakened.

A projection assertion is owned by the policy, under the stable identity (document, name). The code-text-v1 sources select an inclusive one-based line interval from a tracked regular or executable blob, the bytes between distinct exact start_marker and end_marker lines, the whole blob, or the one scalar a TOML or JSON key path names, which Claims describes with contains-v1. Each printable-ASCII marker line is at most 256 bytes, occurs exactly once, and is itself excluded. Amiss does not parse a source language or its comment syntax. Duplicate, missing, reversed, same-line, or non-UTF-8 regions are typed drift, while edits outside the selected region are irrelevant.

The source’s code-text-v1 projection is compared with the semantic Markdown or MDX code block immediately before [amiss:<name>]: <amiss:projection>. It converts CRLF and bare CR to LF and removes exactly one final line ending; it does not normalize indentation or any other byte. The document must be in the scanner’s discovered set. Missing, duplicate, or non-adjacent sinks, and absent, non-blob, LFS, or otherwise invalid sources, produce one projection-drift finding. A marker with no matching policy row remains an unsupported reserved capability and makes the run incomplete. Removing the marker while the policy row survives therefore cannot disable the relation, while removing the policy row is policy-weakened even when the marker is removed too.

A record-value source applies code-text-v1 to one key in a named record-set@1 semantic envelope. Each such envelope carries exactly one record-set object, including empty complete sets, and its records are strictly ordered and unique by key. Keys are nonempty, control-free UTF-8 of at most 4,096 bytes; display values have the same character law and a 65,536-byte cap. The producer’s strings remain inert data: Amiss runs no formatter or template. A row that is present can attest its value even when the envelope says the set is partial. A missing key means source-record-absent only for a complete set; in a partial set it is source-record-unproven, and a missing named set is source-record-set-absent. Evidence derived from an earlier scanner report is not admitted as a projection source.

For a complete envelope, a record-set source applies sorted-rows-v1 to every display value in UTF-8 byte order, or applies decimal-count-v1 to the exact number of records. Duplicate display values remain distinct records because keys, not values, own identity. A partial set produces source-record-set-incomplete for both projections before any equality, count, extra-row, or absence conclusion is attempted. Row-difference previews remain byte-sorted and bounded.

The sorted-rows-v1 projection pairs the same sink with a tree-paths source: one existing tree root, an optional exact suffix, and a positive maximum relative depth. It filters the complete ordered snapshot map without another Git walk or object read, excludes directory entries, and projects all other tracked paths relative to the root. A qualifying non-UTF-8 path or path with a control character is typed drift; an invalid Git path already makes the candidate incomplete. Row mismatches carry exact counts, a pure-ordering flag, and at most 32 rows and 32 KiB of byte-sorted preview on each side, with exact omitted counts.

decimal-count-v1 applies to that same complete tree-paths source and emits only the canonical unsigned ASCII member count. It includes qualifying non-UTF-8 and control-containing path names because none are rendered. A sign, leading zero, grouping separator, label, or whitespace makes the visible value noncanonical rather than a second spelling of the same count.

A document include names one exact path. A tree include names that path and descendants separated by /; specs therefore covers specs/api.md but not specs-old/api.md. Matching is bytewise, including for paths JSON cannot represent as text. A tree may carry one suffix: 2 to 64 UTF-8 bytes beginning with ., with no slash, backslash, or NUL. It selects only non-tree entries at or below that root whose raw path ends in those exact bytes. There are no globs, wildcards, regexes, excludes, normalization, or case folding, and built-in classifications still win. The stable selector identity remains (path, kind), so changing the suffix, removing it, or replacing it with a broader tree reports policy weakening instead of disguising the old selector as a new one. It also means one directory takes one suffix: a second tree include on the same path is a repeated member, and the refusal names its index. A directory whose documents carry two suffixes takes a plain tree include instead, or one suffixed include per subdirectory.

amiss policy-include prints a validated canonical row for the suffixed-tree form without touching the policy file. Its optional staged-index preview applies this same matching implementation and reports exact path identities; it does not invent excludes or merge the row into existing controls.

Document includes, projection assertions, inventory members, and disposition rows share each snapshot’s published repository-policy entry ceiling, so the base/candidate classification union can contain twice that many distinct roots. Tree roots and suffix roots are indexed separately; lookup probes path ancestors and suffix components, never every policy row. The policy tests pin the semantic boundaries, and the release eligibility test checks the maximum union without scanning every policy row for every discovered path. The amiss-scan controls benchmark tracks tree matching, suffix matching, and policy-set comparison as the entry count grows.

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 nullable controls: an organization floor (tightens ceilings and dispositions across many repositories), an adoption debt snapshot (a recorded list of known failures being worked off, mintable from a real evaluation by amiss adopt), a waiver bundle (time-limited permission to pass despite a named failure), trusted time, and an execution constraint. The sealed request also carries a bounded set of semantic-evidence envelopes, each paired with an independently planned context digest, candidate-bound, and interpreted only by a compiled consumer. An ordinary amiss check may instead bind one caller-selected candidate-free template after resolving its exact candidate; that convenience input remains self-asserted and is not an external control.

Every control identity, and the release manifest’s, uses one open repository grammar: a caller-canonical host, a slash-joined owner when the forge supports nested groups, and a repository name. That admits enterprise and self-hosted instances without making them impersonate a public host. In the evaluation request, candidate_ref is the candidate or source branch used to recognize same-repository links; target_ref is the protected branch to which the organization floor, trusted time, debt snapshot, and waiver bundle bind. They are equal for an ordinary branch update but may differ for a pull or merge request. default_branch_ref remains URL-resolution context and does not stand in for the protected target. The organization-floor, debt-snapshot, and waiver-bundle schemas, the control parsers, and their open-forge contract tests pin that grammar and the exact repository/target-ref binding. The execution constraint additionally pins the action tree, release manifest, platform, declared required-status name, and bootstrap in its dedicated parser. A status name is data, not proof of which provider integration published it; source-bound enforcement remains an adapter responsibility.

Trusted time binds more than a timestamp. Its current parser requires the repository and protected target ref, a provider namespace, an opaque bounded provider run ID and positive attempt, and the candidate-identity digest. That candidate identity includes both candidate and target refs, the selected URL dialect, the repository, and the snapshots, so changing any of those cannot replay a statement for the same Git trees. The controls request must repeat the same provider/run tuple, and the verification gate compares it byte-for-byte before using the time.

These are binding rules, not authentication. The controller must authenticate provider input before constructing requests for the exact run. Its provider-neutral sequence and durable retry contract are documented in Controller delivery. The concrete provider lanes load organization policy and their execution constraint outside the checked repository, authenticate a signed webhook or policy-job token, refresh provider-owned change and merge-rule state, acquire the exact trees, derive trusted time, and run the sealed bootstrap. Their separate pages describe the Check Run, policy-job result, or dedicated review that carries provider evidence.

The request’s forge value remains only the URL dialect used by link resolution and is separate from the controller’s provider namespace and instance identity. Debt must reproduce its adoption tree, and a waiver item for another candidate tree is simply not selected. The commit and staged-index paths share one trusted-time, debt, and waiver pipeline.

Debt and waiver require verified trusted time and a complete Git candidate. An item carries its accepted fact, and that fact is the sole source of the finding kind and the key-input preimage; finding_key is recomputed from the nested key. The fact can name only explicit-target-missing or explicit-target-type-mismatch. Selection needs an exact current finding key with a candidate fact; a resolved projection or an absent key is not an exception target. An exact forge commit is part of the normalized target intent, so an exception for the same path at another immutable commit does not match. Matching also requires the exact fact digest. When everything lines up, active unchanged debt records tolerance at warn, and an applied waiver changes only fail to warn. Invalid, expired, worsened, or overlapping items suppress nothing, and an overlap makes evaluation incomplete. Both controls travel only in the sealed request: amiss adopt mints a debt snapshot from the public grammar, but no public flag supplies one back, so consumption belongs to the provider lanes.

The wrapper tests pin binding, trusted-time, expiry, fact-drift, wrong-tree selection, resolved-target, and overlap behavior. The published complete-findings, debt-items, and waiver-items ceilings bound the accepted sets, and the amiss-scan pipeline benchmark tracks matching as findings and exception items grow.

One asymmetry remains in the current control contract: the report can carry a finding on a document whose name is raw bytes, but waiver and debt items spell paths as text. Such a finding is reportable yet cannot be waived or adopted.

The machine-facing evaluation and controls requests are closed by the evaluation-request schema and controls-request schema, with matching strict parser tests. Their unversioned names are intentional: before 1.0 the shipped schema, parser, examples, and report form one rolling contract and move together.

The request reader decodes each supplied control directly into its closed type. Unknown fields, wrong schemas, and non-object payloads reject the request before evaluation. The consumers still verify semantic constraints, expected digests, and authenticated run bindings. The request schema contains the control definitions with local references, so it validates on its own without a schema registry or network fetch.

In the public command and GitHub composite Action, all five external controls are absent and no protected target ref is authenticated. The Action supplies no semantic evidence; a direct public check may carry one self-asserted template. The report records status: "none" separately for organization floor, debt snapshot, waiver bundle, execution constraint, and trusted time; its sandbox assurance is self-asserted. There is no aggregate provider_verified field. The exact projection is built in the report writer.

The sealed bootstrap path can carry all five controls and the bounded semantic-evidence set to the engine. Its report acceptance binds the requested profile; exact organization-floor, debt-snapshot, and waiver-bundle presence, digest, and trust source; the execution-constraint digest, trust source, and recomputed descriptor semantics; the trusted-time digest, provider run, instant, and recomputed statement semantics; every semantic envelope’s payload and producer/input identities after its planned context matches; and the candidate identity and honest sandbox projection. The public CLI shell still supplies each nullable external control as None; its optional template is bound only inside the candidate evaluation and leaves sandbox assurance self-asserted. A report control row with status: "verified" means the engine verified the supplied value’s digest and identity relationships. It does not prove that a provider authenticated or supplied the value: neither the report nor its enum authenticates its own source. The honest reading of a local or convenience-Action report remains: these findings, under this repository policy, with no outside authority consulted. In a provider lane, origin is evidenced separately by the App-owned Check Run, protected policy job, or dedicated review and the matching merge rule; copied report bytes do not become an attestation.

The control-plane finding family closes the loop from the other side. When a candidate weakens its own policy file or drops required coverage, the comparison raises policy-weakened or coverage-reduced. With a verified organization floor, control-plane-changed reports a protected control path unless both base and candidate are present supported blobs with identical path, mode, and raw-content evidence. Loosening the rules is reported under the rules being loosened.

Last change: , commit: 60aacafb

Provider-verified controls

Provider lanes run Amiss behind an identity and merge rule owned outside the repository being checked. They authenticate a provider-created request, refresh the exact change and merge gate, acquire the named Git objects, run the sealed bootstrap, refresh again, and leave evidence in the provider’s protected merge path.

This is separate from the GitHub convenience Action and from calling amiss check in an ordinary job. Those paths are useful scanners, but repository-controlled input does not become provider authority merely because a CI system supplied it.

Supported lanes

Provider familyRequired provider gateAmiss evidenceSupported deployment
GitHubStrict required check bound to one GitHub AppApp-owned Check Run on the test-merge commitGitHub.com and compatible GHES
GitLabEnforced merge train plus an independently owned pipeline execution policy jobThe policy job succeeds only after the exact train result passesGitLab 19.3 or newer, Ultimate
GiteaOne required approval restricted to a dedicated reviewerThat reviewer approves or requests changes on the checked pull requestGitea 1.27 or newer
ForgejoOne required approval restricted to a dedicated reviewerThat reviewer approves or requests changes on the checked pull requestForgejo 16 or newer

All current lanes require SHA-1 repositories, Git protocol v2, a root-mounted HTTPS provider, and an action repository on the same provider instance. Compatible forks are not implied by the table.

The provider-specific setup and configuration live on separate pages:

Common flow

The provider adapter owns authentication, live-state refresh, and publication. The shared controller owns plan selection, replay, leases, the two-refresh race rule, exact acquisition, the supervised process, and durable result staging.

provider_controlssourceprovider-createdrequestauthauthenticateoutside reposource->authfirstrefresh exactchange + gateauth->firstfetchacquire exactrepo + actionfirst->fetchbootsealedbootstrapfetch->bootassessretain externalevidenceboot->assessfinalrefresh gateagainassess->finalsavestage exactresultfinal->saveproofprovider mergeevidencesave->proof

The controller-owned plan binds external_policy into its digest. advisory is the default; off and the opt-in block-confirmed-refutations pilot are defined by The external assessment.

The same plan may name controller-local Intersphinx inventories:

"intersphinx_inventories": [
  {
    "identity": "python",
    "base_url": "https://docs.python.org/3/",
    "file": "/var/cache/amiss/python.objects.inv"
  }
]

The list is optional and holds at most 64 unique identities. Each base is an absolute HTTP(S) directory URL (a missing final / is normalized); each file is an absolute, regular, non-symlink Sphinx v2 zlib inventory. The complete set may occupy at most 16 MiB compressed and 16 MiB decoded, charged while each file is read rather than after the set is resident. The service parses the files at startup with the pinned sphinx_inv grammar, retains only std:label rows whose destinations satisfy the engine’s URI grammar and remain beneath their configured base, and binds the identities, bases, exact source digests, and resulting complete observation set into the controller plan. A malformed, partial, oversized, duplicate, or unresolvable inventory rejects configuration rather than weakening the check.

The service does not download inventory files. Fetch or refresh them in operator-owned deployment automation and point the plan at the resulting local file; a CI or host cache is safe because its exact bytes are re-read, parsed, and digest-bound before use. The repository being checked cannot name or replace this file, and cached download state is never an authority or a repository artifact. See Trusted semantic evidence for the sealed engine boundary.

GitHub, Gitea, and Forgejo arrive as signed webhooks. A bounded receiver authenticates the exact body and stores it before returning 202; a worker authenticates the stored bytes again. GitLab uses a short-lived OIDC token from the policy job and waits synchronously for the result, because the job’s own success is the protected evidence.

One tree, small crates

Each lane is a pair of small crates under controller/: an adapter that speaks one provider’s API and a service binary that deploys it. Provider differences end at those crates. The shared controller stays provider-neutral, the engine gains no provider enum, and the scanner report does not change shape because a forge was added.

Those crates are also a dependency boundary. HTTP clients, provider APIs, credentials, TLS, and service storage live behind them, and no engine crate depends on any of them, so a cargo add amiss closure contains none of it. deny-engine.toml enforces that by dropping the provider crates from the graph and banning the network and async stack in what remains. Auditing the scanner never means auditing a webhook stack.

The lanes are deliberately unpublished: source-built services, not hosted Amiss products, release binaries, or registry crates. One commit of this repository pins everything a lane trusts at once: the engine, the wire contracts, the bootstrap whose digest the execution constraint binds, and the service source. Built at that commit, there is no second repository or registry whose version has to agree with the first. The contracts are pre-1.0 and still move together, so a version seam between engine and service would sit exactly where skew is most dangerous. It also keeps these pages honest: the lane documentation lives beside the lane code, and the repository’s own scan checks the references between them on every change.

Building the provider workspace requires the pinned Rust toolchain and a working C/C++ compiler for its AWS-LC cryptography backend.

Offline configuration check

Before starting a lane, run its service binary with --check and the same absolute config path used at startup. The check uses the service’s strict loader, so it reads and validates the config, the named credentials and trust files, the bound plan, the execution constraint, the bootstrap, the limits, and the path layout.

It then exits before entering the service runtime, binding the listener, opening mutable inbox, ledger, or artifact state, running the bootstrap, or contacting the provider. Success prints the service name followed by configuration valid; failure prints the same configuration error that startup would report.

Every service also answers --version on its own, with no config path, printing its name and version and exiting 0. Use it to confirm which build a host is running before reading anything into a lane’s behavior.

This is a local preflight, not readiness or provider evidence. It cannot prove that the configured address is available, that state roots are writable and healthy, that credentials have the required provider permissions, or that the live merge rule matches the documented setup. Those checks still require startup and retained runs against the provider.

Service operation

Every provider service uses the same three private operator GET endpoints:

PathContract
/healthzReturns 200 while the HTTP process can answer. It is liveness only.
/readyzReturns 200 only after local initialization, and 503 before readiness or during drain.
/metricsReturns the fixed process-local OpenMetrics counters below.

Initialization includes opening and validating the lane’s local state, building its worker or evaluation path, and binding the listener. Readiness becomes false before a requested drain and as soon as supervision observes a worker or maintenance stop, before remaining work drains. A provider POST returns 503 while readiness is false; /healthz can therefore remain live while /readyz correctly removes the process from service.

/metrics has exactly fourteen label-free counters:

CounterCounts
amiss_controller_provider_requests_totalConfigured provider POST requests answered.
amiss_controller_provider_acceptances_totalProvider requests answered successfully, including authenticated no-work deliveries.
amiss_controller_provider_refusals_totalProvider requests refused by authentication, bounds, request shape, or policy.
amiss_controller_provider_unavailable_totalProvider requests that returned an unavailable result.
amiss_controller_delivery_attempts_totalDurable deliveries attempted by a webhook worker.
amiss_controller_delivery_completions_totalDurable deliveries completed.
amiss_controller_delivery_retries_totalDurable deliveries left for retry.
amiss_controller_delivery_discards_totalDurable deliveries removed after failed reauthentication.
amiss_controller_maintenance_runs_totalLedger maintenance scans completed.
amiss_controller_maintenance_removals_totalDurable records, reports, and temporary entries removed by maintenance.
amiss_controller_external_refuted_totalExternal destinations a retained assessment refuted.
amiss_controller_external_unproven_totalExternal destinations a retained assessment left unproven.
amiss_controller_external_reachable_totalExternal destinations a retained assessment found reachable.
amiss_controller_external_incomplete_totalExternal verifications that could not finish.

The set cannot grow from a repository, request, provider identity, or result. It has no labels, and all values reset on restart. Counters that do not apply to a lane remain zero. The metrics endpoint remains scrapeable during drain until the listener closes; it does not make the listener safe to expose.

Each lane also serves the separately configured, bearer-authenticated artifact GET route. Retained provider artifacts defines its URL, response, exact-byte, expiry, retry, and storage contract. Artifact retrieval does not increment the fixed provider request counters.

Runtime lifecycle events are one compact JSON object per stderr line. The schema is amiss/controller-event/v1, and the only keys are schema, level, event, and component. Normal transitions are ready, draining, and stopped, with level info and component service. A required background component failure uses event failed, level error, and component worker or maintenance. It appears before draining when the component initiates shutdown and after draining when admitted work fails while finishing.

{"schema":"amiss/controller-event/v1","level":"info","event":"draining","component":"service"}

These events deliberately carry no request body, header, credential, repository, path, object ID, provider reply, or free-form error. This keeps lifecycle logging bounded and avoids echoing secret-bearing input.

On a termination signal, the service marks itself unready before it stops accepting new work. The HTTP server finishes requests already in flight. A webhook worker finishes its current delivery and leaves the remaining durable inbox backlog for the next process. The synchronous GitLab lane finishes admitted evaluations and any ledger maintenance already running. This includes blocking work whose provider connection closed after admission. A second termination signal aborts a stuck drain. Do not depend on a final metrics scrape after drain starts: the listener may close before the other components finish.

Bind this listener only to loopback or a private operator network. If a TLS proxy accepts provider traffic, publish only the configured provider POST path and artifact GET prefix through it; keep /healthz, /readyz, and /metrics private. None of the three operator endpoints is authenticated; the artifact prefix has its own bearer authentication.

Shared trust boundary

Run a provider service on a host controlled independently of the checked repository. Keep its API credential, webhook secret or OIDC keys, bootstrap, execution constraint, optional controls, artifact bearer token, scratch directory, file-ledger root, and artifact root outside the repository and action trees. Webhook lanes also have a separate raw-inbox root. All roots must be pre-created private local directories; shared and network filesystems are unsupported.

Build the constraint from the exact local action and bootstrap bytes with Prepare the execution constraint. The generator checks every dependency lock and the selected platform’s runtime closure but does not authenticate either input; independent acquisition and protected placement remain operator responsibilities.

The listener is plain HTTP. Bind it to loopback or a private network and put an operator-controlled TLS terminator in front. The proxy must preserve signed headers and the exact body, and must cap connections plus total, header, body, idle, and slow-body time. /healthz reports only process liveness; the full probe and drain contract is in Service operation. A webhook service also takes one of its configured delivery permits before reading a body and holds it through durable inbox admission. That bounds in-process work. Both endpoint shapes stop an unfinished body after 30 seconds; neither limit replaces the proxy’s public connection limits.

The hard ceilings are shared by every lane:

CeilingValue
Request body8 MiB
Header count128
Aggregate header bytes32 KiB
Ledger rows100,000
Artifact records100,000
Artifact total64 GiB
One artifact record1 GiB
Artifact retention365 days
In-process endpoint concurrency64
Webhook inbox rows1,024
Webhook inbox total128 MiB
One webhook inbox row16 MiB

A provider service may clamp these lower; the GitLab policy-job endpoint, for example, accepts at most a 1 KiB body and 32 headers. GitHub and Gitea-family completion rows cannot age out because their signatures contain no trusted time. Their provider pages describe the required secret-and-ledger cutover before that finite record cap fills.

The service and the provider evidence cannot update in one transaction. Exact artifacts and then the result are saved locally before an external provider update or GitLab’s synchronous success response. An ambiguous reply may therefore require reconciliation, and each provider page states what can and cannot be repeated safely. The file ledger and artifact store use bounded, checksummed ordinary files and atomic replacement; neither uses SQL or an embedded database.

Provider administrators, repository administrators who can change the protected merge rule, integration owners, policy-project owners, credential issuers, configured bypass actors, and anyone who controls the service host or its trust files remain inside the lane’s trust boundary. A lane proves only what those authorities jointly enforce.

What the report means

The engine report remains the same canonical evaluation envelope. It is not signed by the provider or controller, its sandbox assurance remains self-asserted, and it has no provider_verified field. A control with status: "verified" means that the engine checked the control’s digest and identity bindings; it does not identify the caller.

Provider origin lives in the provider gate: the GitHub App-owned Check Run, the GitLab policy job, or the dedicated Gitea-family review, together with the matching protected-merge settings. Copied report bytes alone are not an attestation.

Last change: , commit: 0987dade

Cross-repository relations

Documentation and the implementation it describes may live in different repositories. A change to either one can introduce drift while the other repository remains byte-for-byte unchanged. The controller has the operator-owned registry and exact Git acquisition boundary needed to identify and materialize those relations without letting repository content choose another repository, credential, selector, limit, or publication target.

This is not yet a complete cross-repository checking lane. The shared service can load one bounded operator file into an immutable registry, and the controller can bind the complete set of opaque credential identities to caller-owned authorities. The service can also bind one opaque operator-declared coordination identity to the exact relation owned by an authenticated delivery, then freeze caller-resolved revisions only when the live registry still admits that relation and the trigger candidate reproduces the delivery. No provider service supplies that registry or constructs those authorities yet. The controller derives the canonical audit plan directly from the accepted trigger report and frozen transition. The service can project repository-backed sources from all four acquired snapshots, assess the result, retain the exact chain, and stage its destinations under the current scheduling fence. No provider service assembles those inputs into a live lane yet. The controller independently replays every supplied audit before storage and can reconcile a claimed GitHub or Gitea-family destination. The service can drain reopened claims through the frozen credential router and acknowledges only a provider-confirmed delivery. An authenticated live GitLab policy job can instead consume its exact synchronous destination.

One closed relation

One relation contains exactly two subjects and one of the existing projection kinds: code-text-v1, sorted-rows-v1, or decimal-count-v1. contains-v1 is not one of them, since two digests say nothing about one value sitting inside the other. Reusing the scanner’s projection vocabulary keeps copied text, exact inventories, and counts under one comparison model instead of adding a second selector language.

Each subject carries:

  • a relation-local role;
  • the exact provider family and instance, integration, and canonical repository identity;
  • the operator-selected target branch and Git object format;
  • an opaque credential reference, never credential bytes;
  • one projection source checked through the scanner-policy source grammar; and
  • independent acquisition-object, acquisition-byte, projection-record, and projection-byte limits.

The relation adds an identity, aggregate limits for the same four resources, and one or two status destinations. A destination names one of the two subject roles and a status name under the existing required-status grammar. The same credential reference may be configured for both subjects when a provider genuinely issues one appropriately scoped identity; the registry still retains the choice separately on each subject.

Construction is atomic. It rejects more than 1,024 relations, repeated relation identities, equal subject roles, equal repository identities, malformed or projection-incompatible sources, zero or overflowing limits, aggregate limits that cannot admit either subject or exceed both subject ceilings together, missing or repeated destinations, foreign destination roles, and malformed status names. Two relations also cannot own the same provider-instance, repository, and status-name key, even through different integrations or credentials. Subject, destination, and relation order is canonicalized before the private trigger index is exposed.

There is no mutation API. A successful construction owns immutable relation plans behind shared references; changing operator configuration requires building and installing another complete registry. A rejected construction exposes no partial index and cannot replace a live entry.

Credential routing is another atomic construction over that frozen registry. Every distinct opaque credential identity must have exactly one caller-owned authority under the provider instance and integration named by its subjects. One authority may cover several registered repositories under that scope. Missing authorities, extra or repeated rows, and reuse of one identity under another provider or integration are rejected. Lookup repeats the subject binding before returning the authority. Reopened status-target lookup repeats the same credential and provider-scope check, and neither the registry nor the router exposes a mutation API. The authority value can directly contain its concrete provider client and Git acquisition credential; the controller does not erase it behind another provider trait or interpret its secret bytes.

Authenticated triggering

Both subjects are trigger owners by construction. This removes a configuration branch that could silently check changes from only one side.

Lookup accepts an AuthenticatedDelivery, not a repository path, URL, webhook body, or policy file. Its key is the authenticated provider instance, integration, repository identity, and object format. Provider facts that disagree inside the delivery are an error. A coherent delivery outside the registry is ordinary authenticated no-work. A matching delivery returns every affected relation in stable relation-identity order together with the role that triggered it.

Coordination admission consumes the authenticated delivery and accepts only an operator-declared relation among that exact trigger set. Its result keeps the delivery, frozen relation, trigger role, and bounded opaque coordination identity together for execution. An unknown relation or an internally inconsistent delivery is an error. The service does not expose a coordination-policy enum or derive identity from a commit, timestamp, branch name, URL, or provider event spelling.

The configured target branch is not treated as an immutable revision. A provider resolver must refresh it and supply exact base/candidate commit and tree IDs for each role. The controller freezes all four revisions against the registered roles and object formats before acquisition. The trigger role’s candidate commit must also reproduce the delivery-authenticated provider run. The same trusted call supplies one bounded coordination identity naming the exact pair, release, or workflow occurrence. The relation configuration and its context digest define what that identity means; Amiss treats the spelling as opaque. Commit timestamps, nearby branch heads, URL versions, and repository prose are not pairing evidence.

Status preparation requires a fresh head fact for both subjects, not only the repository whose delivery triggered the audit. Each fact must reproduce the complete registered subject, including its provider scope, target, credential identity, selector, and limits. A changed subject binding or object format is invalid; a changed candidate commit or tree is superseded. Only then does the controller freeze the configured destination roles into a stable batch carrying the relation, coordination, trigger role, pending fence, exact provider scope and credential identity, candidate commit, and required status name. Selector, target-branch, and resource-limit fields have already served their finality proof and are not copied into the provider outbox. An unconfigured role never appears in that batch.

Pending and supersession law

The provider-neutral scheduler is a pure transition over an optional pending value and one newly frozen relation transition. The first exact value receives fence 1. Repeating the same operator plan, coordination identity, and four subject snapshots is a duplicate and preserves the original pending value, even when the other authenticated role triggered the repeat. A coordination identity cannot be rebound to different snapshots, and a relation identity cannot be rebound to different operator configuration.

A different coordination identity under the same relation advances the fence and becomes the new pending value. A worker holding the earlier fence is therefore superseded, while an audit it already retained remains immutable under its own artifact identity. Fence overflow fails closed. This model contains no clock and gives no lexical meaning to coordination identities.

The file-backed admission store applies that law under one cross-process lock. An atomically replaced committed head bounds a hash-chained append-only journal, so restart either observes the whole new binding or discards its uncommitted suffix. Every admitted coordination remains bound to its first exact work and fence: a delayed retry returns that historical fence but cannot become current again. New work appends one bounded record instead of rewriting all history, the immutable capacity applies only to new bindings, and a missing, shortened, reordered, rebound, or malformed committed record fails closed. The journal retains full configuration and work digests rather than credential references or complete operator configuration. Head-final status preparation is pure and does not authorize an external write: a later durable publisher must still stage the exact batch while proving its fence is current. Invoking either boundary from a live relation lane remains a separate stage.

The pure staging transition models that next boundary without choosing its disk format. It accepts only the current pending fence and fresh two-subject head facts, replays the complete relation audit against the pending transition, and binds its retained artifact reference and verdict to the exact destination batch. A first stage returns one direct record. An unfinished exact retry returns that same record, a completed exact retry returns no work, and substituting any target or audit field is a binding conflict. Completion changes only the terminal bit and is idempotent for the exact staged value. The file-backed relation journal applies that transition under the same cross-process lock as scheduling, so current-fence verification and the committed stage cannot race. The stage action first verifies the live retained artifact, then stores the relation identity, coordination, trigger role, fence, artifact identity, and one domain-separated binding over the complete typed target and audit record. It does not duplicate provider configuration or credential identities in the journal. Each configured external destination is retained in the staged action as a domain-separated digest of its stable provider-instance, repository, and status-name identity. After a provider accepts or reconciles the exact staged value, a separate hash-chained action acknowledges that destination. A foreign or repeated destination fails closed, and batch completion is refused until every staged destination has a durable acknowledgement. Completion is itself a separate hash-chained action; an exact retry is idempotent, while a missing or rebound record fails closed. Completed in-memory state keeps only the status binding digest.

Restart recovery now performs that reopening without persisting a second provider configuration. It reads the bounded retained relation audit by artifact identity, reconstructs the frozen transition from its exact snapshots and the immutable registry, replays the audit, and returns the staged batch only when the complete status binding is identical. A missing or rebound registry, expired artifact, or changed target remains a refusal. Reopening still grants no external delivery authority.

Delivery claims use 256 deterministic operating-system lock shards derived from those stable destination digests. For every destination, selection retains only its lowest unresolved fence; candidates sharing a shard are tried in stable fence, relation, and coordination order. The shard is acquired without holding the journal lock, then the journal is synchronized and selection is repeated before the registry and artifact are reopened. This lock order lets acknowledgements take the journal lock without deadlock. A newer coordination cannot reach the same destination first, while unrelated shards may proceed in parallel. A hash collision can reduce concurrency but cannot change selection or authority.

The returned claim directly carries the exact reopened status record and one target while a private file handle keeps its shard locked. Dropping it, including process failure, changes no durable state; the next attempt selects the same oldest unacknowledged destination. Acknowledgement consumes the claim and appends only after provider acceptance or provider-specific reconciliation. The final acknowledgement also appends completion under the journal lock. If failure lands between those two commits, the next claim pass completes the fully acknowledged batch before selecting more provider work.

The durable store still makes no provider call. A provider-neutral service loop obtains one claim, selects its authority from the frozen credential router, and passes the exact status and target to a caller-supplied publisher. Only publisher success consumes the claim and appends its acknowledgement. A routing or provider error drops the held shard unchanged, so restart selects the same durable destination again. Provider adapters must reconcile an ambiguous response for that exact value before returning success. The loop stops only when no destination is currently claimable; a live relation lane remains responsible for polling it again.

The GitHub installation client can independently refresh the final head of an operator-configured GitHub subject. It accepts the typed subject directly, requires the configured provider and installation, a canonical repository on that provider instance, and SHA-1, then resolves the declared branch and validates both the returned commit and its tree. The result is a typed RelationSubjectHead; it does not choose a credential, schedule work, or authorize publication. Credential routing and the two-subject finality decision remain controller-lane responsibilities.

The same client accepts one exact status record and one destination from a durable delivery claim. It rejects a completed or malformed batch, a target not present exactly once, another provider or installation, a noncanonical repository, a non-SHA-1 candidate, or an inconsistent relation audit before provider I/O. The check is attached to the subject candidate commit under the operator-configured relation status name. aligned and resolved-drift conclude success; introduced-drift, pre-existing-drift, and unproven conclude failure. Its credential-free summary binds the relation, coordination, fence, roles, target, verdict, and report, plan, evidence, and assessment digests. A domain-separated digest of that complete projection is the external ID.

Before creating anything, the client lists the App-owned checks for that exact commit and name. It reuses one exact external-ID and output match, creates only when none exists, and rejects duplicate or conflicting matches. A lost create response therefore leaves the claim unacknowledged; a retry normally reconciles the accepted run before the caller durably acknowledges the destination. The GitHub API and local journal still have no shared transaction, so a stale provider read can expose a duplicate later and make subsequent reconciliation fail closed.

The Gitea-family client independently refreshes an operator-configured subject through the authenticated commit endpoint. It requires the configured provider and dedicated reviewer, a flat canonical repository on that provider instance, and SHA-1 before resolving the declared branch. Both the returned commit and tree must be exact SHA-1 names. The result is the same typed RelationSubjectHead used by provider-neutral finality; full object acquisition remains a separate bounded Git operation.

The client projects the same checked, credential-free value onto the exact candidate commit through the native commit-status API. It applies the same scope requirements before listing statuses. The list is bounded to 1,000 rows and ordered by the provider’s commit-status index. For the configured context, the client reuses an exact latest row, advances a different valid Amiss marker written by the same reviewer, and rejects a foreign, malformed, or same-marker conflict. A create succeeds only when the provider echoes every field and the exact reviewer.

The short versioned description carries a domain-separated digest of the same complete projection; the status state carries success or failure. Gitea and Forgejo do not bind a required status context to its writer, so checking the response’s reviewer protects Amiss reconciliation but cannot make the provider merge rule identity-secure. Use this surface for an unchanged relation subject only with that limitation understood; publish the actual protected gate on an identity-bound destination when one is required.

GitLab has no asynchronous relation publisher. Its adapter accepts an already authenticated policy job and a relation subject only when both name the configured project, integration, SHA-1 format, and protected target branch. A fresh provider refresh must still prove that exact job, pipeline, merge-train car, candidate, runner, policy origin, project controls, and branch protection active. The resulting head fact is the ephemeral merge-train candidate, not the mutable protected-branch head.

The same live-job boundary can consume one staged relation destination. Its scope and candidate must match the authenticated delivery, and its status name must be the exact configured policy job name. The shared relation projection supplies only a pass or block decision; another final refresh must still find the job and train active before the caller lets the endpoint return success. No GitLab API write occurs. A stopped job cannot be resumed by a background publisher, and a lost success response still fails the provider job closed even if Amiss retained the completed local result.

Exact Git acquisition

The existing strict HTTPS protocol-v2 shallow fetch now accepts a positive object and pack-byte ceiling and returns its measured pack usage. Caller ceilings can only narrow Amiss’s global 2,000,000-object and 2 GiB pack ceilings. The streaming pack validator enforces the selected limits before indexing, so an oversized response is never accepted and counted afterward.

Relation acquisition sorts inputs by role, binds each canonical HTTPS repository URL and opaque credential identity back to the operator plan, and fetches both exact commits for each subject into its own root. The first subject’s measured usage is subtracted from the aggregate budget; the second receives the smaller of its own ceiling and what remains. Both roots are then reopened by the bounded repository reader, and every commit must name the independently resolved tree.

Any missing object, transport failure, cancellation, exhausted budget, wrong tree, or aliased root makes the complete relation unproven; no partial relation result is returned. Roots must remain physically distinct even when two independent repositories happen to produce identical Git object IDs. SHA-256 subjects remain representable in the registry but are unproven through the current SHA-1-only provider transport.

Report-bound audit plan

After the four snapshots are frozen, the controller derives a separate 64 KiB wire plan from that transition and the accepted trigger report. The canonical plan records the exact comparison that later evidence must reproduce. It contains:

  • the accepted scanner report payload digest and the role whose authenticated change selected it;
  • the relation identity and a context digest for the complete operator-owned configuration;
  • the exact operator-supplied coordination identity;
  • one shared projection kind; and
  • exactly two role-sorted subjects, each with its canonical repository, selected target branch, compatible source selector, object format, and exact base/candidate commit and tree IDs.

The coordination identity records intent and the four object pairs identify its exact comparison; neither is derived from the other. It does not impose an order, deadline, or lifecycle by itself. The target branch remains explanatory selection context. Base and candidate may be identical for an unchanged subject, and the two subjects may use different object formats. Repository identities and roles must differ, and the trigger role must name one subject. Credentials, raw provider tokens, and transport budgets are deliberately not copied into the portable document.

The checked writer and strict reader share the scanner’s existing projection-source grammar. A code-text-v1 plan therefore accepts blob lines, a named region, a whole blob, one key’s value, or one record value; sorted-rows-v1 and decimal-count-v1 accept tree paths or one record set. The plan does not add a second selector language or let either repository change the operator-owned selector.

The context digest is an integrity binding for operator configuration interpreted outside the wire; it is not authority by itself. Likewise, the report digest and trigger role are closed facts, but the wire reader does not have the report and cannot prove they agree with it. Controller admission performs that binding before retaining the sidecar. The plan intentionally contains no projected value, completeness claim, alignment verdict, blame assignment, or status policy. Malformed branches, selectors, identities, object IDs, subject ordering, unknown fields, and a changed payload refuse the whole document.

Projection evidence

A second closed 64 KiB document binds projection evidence to the exact plan payload digest. It has the same two byte-sorted role rows, each with independent base and candidate slots. A slot is either null or one complete projected value:

  • value_digest is the plain SHA-256 of the exact canonical projected bytes; and
  • value_bytes is the nonnegative safe-integer length of those same bytes.

Null means that producer did not establish one complete value for that exact slot. It does not mean an empty value, a missing source, or a mismatch, and it cannot participate in an equality claim. There is no complete flag that can disagree with nullable digest fields and no partial projected value whose absence claims would be ambiguous. All four slots may independently remain null; a missing evidence document remains distinct from a present receipt that records four unproven attempts.

The projection kind in the plan defines the canonical bytes. For code-text-v1, blob-line and named-region selections, a whole blob, and a key’s value normalize CR and CRLF to LF and remove one terminal LF; record values use their exact UTF-8 value bytes. sorted-rows-v1 byte-sorts the complete selected rows and joins them with one LF and no trailing LF. decimal-count-v1 uses the canonical ASCII decimal item count without leading zeroes. The compact receipt does not copy potentially multi-megabyte values merely to compare them twice.

After acquisition, the Git projector rebuilds the plan, binds every repository, selector, commit, and tree back to the frozen operator relation, and reopens the two physically independent roots. It visits roles in canonical order and each role’s base before its candidate. Each snapshot receives the smaller of the subject budget and aggregate budget that remains. Blob-line and named-region sources count one selected record and charge the larger of the source blob or canonical projected value. Tree-path sources count every selected path and charge the larger of their combined path bytes or canonical output. A crossed budget or untrusted Git object refuses the complete operation; a missing, unsupported, or incomplete repository source records a null slot. Record-value and record-set slots also remain null until a separately authenticated, snapshot-bound producer exists.

The evidence reader establishes shape and payload integrity only. Repeating a plan digest is not producer authority, and matching role spellings are not checked until the plan and evidence are assessed together. Unknown fields, malformed digests or roles, reordered or repeated rows, unsafe byte counts, and a changed payload refuse the whole receipt. The evidence contract carries no verdict and never identifies which subject should change.

Equality transition

A third closed 64 KiB document records the deterministic offline assessment. It binds the accepted report, exact plan, optional evidence, and evaluator version and digest. Before comparing values, the evaluator rebuilds both supplied envelopes, requires the evidence to name the exact plan, and requires its two role rows to match the plan. Any absent evidence, foreign plan digest, mismatched role, or null projection slot yields unproven with one corresponding reason.

For four complete slots, projected values are equal only when both their digest and byte length are equal. The two booleans map to exactly one transition:

Base equalCandidate equalVerdict
yesyesaligned
yesnointroduced-drift
nonopre-existing-drift
noyesresolved-drift

These names describe equality over time, not correctness. Both roles participate symmetrically; the assessment neither chooses an authority nor says which repository should change. A proved transition carries a null reason, while unproven carries exactly one of evidence-absent, evidence-unbound, role-mismatch, or projection-unproven. Inconsistent verdict/reason/evidence combinations and a changed payload refuse the assessment instead of being normalized.

The assessment is replayable over the exact bound documents, but the portable evidence document does not authenticate its producer by itself. The acquisition projector establishes repository slots under the operator-owned relation and resource limits. Before storage, the controller reopens the accepted scanner report, binds its repository, target, and exact snapshots to the trigger role, rechecks the operator plan, and independently replays the evidence and assessment. The artifact store retains the report, plan, optional evidence, and assessment under one immutable, restart-safe identity; retries may reproduce the same bytes but cannot substitute any component.

The service execution boundary composes those existing checks as one Result flow. It first rejects work whose fence is already stale, derives and parses the canonical plan, projects both independent roots, assesses the typed evidence under the caller-supplied evaluator identity, retains the complete audit, and asks the durable scheduler to stage its exact head-checked destinations. The early fence check avoids known-wasted projection work; it is not publication authority. Status staging repeats the fence and final-head checks under the scheduling lock, so supersession racing the projection can still expose no destination. An exact unfinished retry reproduces the retained artifact and staged record instead of rebuilding a parallel orchestration path.

Trust boundary

The registry lives only in the unpublished controller layer. The offline engine still has one declared repository root, no network or async dependency, no credential input, and no ability to follow a link into another repository. Projection sources in this registry are operator input; similarly shaped repository policy does not add a relation or gain access to its credential reference.

The remaining live-lane stages are deliberately separate:

  1. construct concrete provider authorities and install the frozen registry and router in a service;
  2. resolve and acquire snapshot-bound records from the admitted coordination in live provider lanes.

Until those stages exist, the service can execute and stage an exact bounded repository comparison when its caller supplies the current pending transition, accepted report, two acquired roots, final heads, stable evaluation identity, and evaluator identity. Given a frozen router and provider publisher, it can also resume and drain the retained destination batch after restart. No provider service yet assembles the complete relation lifecycle.

The implementation is in the provider-neutral relation registry, the bounded registry loader, the exact relation transport, the repository projector, the durable scheduler, the relation laws, and the durable scheduling laws exercise bidirectional selection, stable ordering, four-revision binding, independent roots, projection compatibility, joint budgets, exact destinations, restart recovery, delayed retries, capacity, corruption, and concurrent admission.

Last change: , commit: dd6bc030

Prepare the execution constraint

Every provider lane loads one execution constraint from operator-owned storage. It pins the action repository, exact action commit and tree, release manifest, target platform, stable result name, and the exact bootstrap executable the service may run.

amiss-constraint builds that existing contract from a local action checkout and bootstrap. It does not introduce another configuration format.

Build

Build the bootstrap and the companion tool from the same reviewed source commit as the provider service:

cargo build --release --locked -p amiss-bootstrap --bin amiss-bootstrap
cargo build --release --locked \
  -p amiss-controller-constraint --bin amiss-constraint

Both land in target/release; no system-wide installation is needed. Windows binary names carry the normal .exe suffix.

Acquire the published action repository independently, as an ordinary non-bare checkout with a real .git directory. Choose and record the full immutable action commit. The checkout must already contain that object; the tool does not fetch, run git, read a ref, or consult HEAD, remotes, and worktree files.

Then create a new constraint file:

target/release/amiss-constraint \
  --action-repository /absolute/path/to/action-checkout \
  --action-identity github.com/example/amiss \
  --action-commit eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee \
  --bootstrap /absolute/path/to/amiss-bootstrap \
  --required-status-name "amiss / assure" \
  --output /absolute/path/to/execution-constraint.json

A lone --version reports the tool’s own version instead of building anything, which is the way to confirm the producer matches the release it is provisioning for.

Use native absolute paths on the deployment system. --action-identity is the separately supplied logical host/owner/name; a nested GitLab owner such as gitlab.example/platform/security/amiss is valid. The tool does not read a Git remote or compare that identity with the manifest’s build source. For a provider-local mirror, use the exact commit present on that provider even if recreated commits differ from upstream.

--action-commit is the action-tree commit consumers pin, not the source commit recorded inside the release manifest. Current provider lanes require a full 40-character SHA-1 commit, and this provider-facing command rejects every other object format.

The output path must not exist. The tool validates everything before publishing the canonical file and never replaces an existing file. On success it prints the constraint’s semantic digest, which is useful for the deployment record but is not a signature.

The bootstrap and output parent must resolve outside the action checkout. Create a private output directory first. Generate the file as the service account or have the deployment mechanism make the new file readable by that account without exposing it to the checked repository.

Supplied and derived values

Only the values that require an operator decision are supplied:

Supplied valueMeaning
Action checkoutThe local primary checkout that already contains the action commit.
Action identityThe forge host and repository whose action tree is trusted.
Action commitThe independently selected full commit ID already present in the local object store.
BootstrapThe exact local executable that the service will later load.
Required status nameThe stable result name bound into controller state and the report.

The rest comes from those exact bytes:

Derived valueSource
Object formatThe current provider contract’s fixed SHA-1 namespace, confirmed while reading the commit.
Action treeThe tree named by that commit object.
Manifest pathThe release’s fixed release-manifest.json path.
Manifest digestThe parsed manifest’s semantic digest.
Target platformThe bootstrap executable header.
Bootstrap digestThe domain-separated digest of the bootstrap bytes.
Schema, bootstrap contract, descriptor digestThe existing wire constructor and canonical writer.

The tool resolves the manifest, dependency locks, engine, and action metadata from the pinned Git tree. It checks every dependency-lock digest and every mode and digest in the selected platform’s runtime closure, then requires the engine and bootstrap headers to name the same platform. It also requires release-manifest.digest to reproduce the parsed manifest digest. That small file is a consistency marker, not a trust anchor; the semantic digest is recomputed from the manifest itself.

Trust and rotation

Generation proves the supplied action object store, commit, manifest, and selected runtime closure are internally consistent, and that the runtime and bootstrap headers name the same platform. It does not authenticate where those inputs came from, select the trusted commit, prove that the supplied executable is an Amiss bootstrap, sign the output, or make a report provider-verified. It binds the bootstrap’s exact bytes. The operator-controlled acquisition, service host, and deployment storage must protect input origin and program identity.

Keep the action checkout used for preparation read-only. Store the generated constraint, bootstrap, provider credentials, and optional controls outside both the checked repository and the action checkout. Protect them from the repository and its CI identities.

Generate a new file when the action repository or commit, bootstrap executable, or required status name changes. Use a fresh versioned output path because the producer never overwrites. Review its printed digest, update the service configuration and provider gate as one change, then retain the old deployment material until in-flight work under the old binding has drained. Do not regenerate the constraint automatically at service startup: that would turn observed drift into newly trusted input.

Last change: , commit: 40368f5f

Retained provider artifacts

Provider summaries are intentionally short. A publication can carry more findings than GitHub’s Check Run or a Gitea-family review should display, and an external assessment is useful only with the exact plan and evidence that produced it. Each provider lane therefore retains those bytes in an operator-owned artifact store before it stages or publishes the result.

The store is evidence retention, not replay or acceptance authority. The FileLedger still decides which delivery may run, retry, or complete. Repository content cannot configure the artifact root, URL, token, lifetime, or capacity, and retained bytes never suppress or approve a later finding.

Published binding

Every report-bearing provider publication binds all of the following:

  • the canonical report digest;
  • when semantic evidence was accepted, the canonical semantic-input audit digest;
  • an HTTPS report locator;
  • the authorization scheme, bearer;
  • the exclusive expiry instant in Unix milliseconds; and
  • when external verification completed, the canonical assessment digest.

GitHub Check Runs and Gitea-family reviews carry these as report, artifact, artifact-auth, artifact-expires-unix-millis, and optional semantic-input, semantic-input-artifact, and assessment lines. GitHub leaves the Check Run’s native details URL unset because that browser link cannot supply the required bearer header; authorized clients use the locator in the summary. A completed external assessment adds its direct assessment-artifact locator and its refuted, unproven, and reachable counts; an incomplete one is named as incomplete instead of inventing counts. GitLab returns the report locator as Link: <...>; rel="amiss-report", the semantic-input sibling as rel="amiss-semantic-input", and the assessment sibling as rel="amiss-assessment" when each exists. It returns authorization, expiry, assessment state, and completed counts as X-Amiss-* headers. The component digests are X-Amiss-Report-Digest, X-Amiss-Semantic-Input-Digest, and X-Amiss-Assessment-Digest. The report URL always ends in /<artifact-id>/report; retained semantic inputs use the sibling /semantic, and exact external inputs use /plan, /evidence, and /assessment.

The controller writes the exact report, accepted semantic-input audit value, and optional external chain before its final provider refresh and publication stage. The artifact identity binds the evaluation ID, every component digest, and the external outcome. A retry first verifies the saved reference and every retained component, then republishes the already staged value. It never reruns the scanner, semantic producer, or external verifier. A changed head or gate after verification stages a superseded result with the retained chain, not the old pass or block. Rebinding one evaluation ID to different bytes is an error.

The same store can retain a validated publication audit as a separate immutable record. Its reference carries the ordinary report artifact plus the exact plan, optional evidence, assessment, and verdict digests. The record survives restart and reopens only when its metadata, every retained byte, and the evaluation binding still agree. Publication audit components use the distinct siblings /publication-plan, /publication-evidence, and /publication-assessment; /plan, /evidence, and /assessment remain the external-link verification chain. This storage surface does not acquire a deployment receipt or publish an audit outcome. A later audit lane must retain the validated chain before staging any provider-visible result.

If retention, validation, or retrieval cannot be trusted, a new publication fails closed. A summary without a retained locator says extra findings are “not displayed”; it never claims that an inaccessible report exists. Expiry cannot change a provider verdict that already completed. After expiry, a duplicate delivery may still be recognized by the delivery ledger but no longer advertises an artifact.

Authenticated retrieval

The configured base_url must be a canonical HTTPS URL with a non-root static path and no credentials, query, fragment, empty path segment, or trailing slash. Route segments use only letters, digits, -, ., _, and ~. The TLS proxy must forward that path and preserve the Authorization header without exposing the three private operator endpoints.

The service reads one 32-to-256-byte bearer token from a bounded regular file at startup and keeps only a keyed verifier in memory. Give the token only to authorized authors or operators. Retrieve the report exactly as published:

AMISS_ARTIFACT_TOKEN="$(</etc/amiss/artifact.token)"
curl --fail --silent --show-error \
  --header "Authorization: Bearer ${AMISS_ARTIFACT_TOKEN}" \
  'https://amiss.example/amiss/artifacts/<artifact-id>/report' \
  --output report.json

When the publication advertises a semantic-input component, retrieve its exact source templates and candidate-bound envelopes with the same token from https://amiss.example/amiss/artifacts/<artifact-id>/semantic. Recompute the advertised digest before using any component as audit evidence.

An authorized publication-audit client retrieves its three components from the publication siblings above. An unproven audit intentionally has no /publication-evidence component; its assessment binds a null evidence digest instead. The immutable artifact identity binds the audit digest set and verdict; callers must keep that complete reference with any staged audit outcome.

Token files are exact bytes and cannot contain whitespace, including a trailing newline. Changing the token requires a service restart but not a new artifact root. If consumers must keep access to old locators, retain the old token until their published expiry instants.

An authorized GET returns the unchanged JSON bytes with Content-Type: application/json, Cache-Control: private, no-store, and X-Content-Type-Options: nosniff. Missing or wrong authorization returns 401; an unknown component or expired artifact returns 404; a query or oversized header set returns 400; unavailable storage or request capacity returns 503. Artifact requests share the configured endpoint header and concurrency bounds but do not change the fixed provider-request counters. Their own semaphore additionally derives a response-memory cap from artifact_record_bytes: at most 128 MiB of configured component bounds can run together, except that one explicitly allowed component may itself be as large as the fixed 256 MiB machine JSON ceiling. This is a concurrency bound, not a startup allocation.

Storage and limits

paths.artifacts names a fourth pre-created private local directory, separate from scratch, ledger, and the webhook inbox. One process owns the root. Shared and network filesystems, symlinks, unknown entries, malformed metadata, missing payloads, and digest mismatches fail closed. Metadata is checksummed, payloads are digest-checked, creation is metadata-last, and deletion is metadata-first, so an interrupted operation is either recoverable debris or a complete record.

The optional execution-limit fields are:

FieldDefaultHard ceiling
artifact_retention_seconds604,800 (7 days)31,536,000 (365 days)
artifact_records1,000100,000
artifact_bytes1 GiB64 GiB
artifact_record_bytes64 MiB1 GiB

All values must be positive, and one record’s limit cannot exceed the total-byte limit. Record and byte accounting includes metadata plus every retained component. Full capacity rejects new evidence; it never evicts a live artifact. At the exact expiry instant the artifact becomes inaccessible. Startup and store operations remove expired records, and a persisted clock high-water mark prevents already removed bytes from returning after clock rollback.

The base URL, retention, and capacity limits are recorded with a root. Changing any of them requires a new empty artifact root. Size the record limit for the largest report, semantic-input audit value, external chain, or publication-audit chain a lane is allowed to retain, and size total bytes plus record count for the expected publication rate over the retention period.

Last change: , commit: 77195531

GitHub provider lane

The unpublished amiss-controller-github-service crate serves one GitHub repository, one App installation, and one protected target branch. It supports GitHub.com and compatible GitHub Enterprise Server (GHES) releases, and it is built from source rather than distributed as a hosted service, container, or release binary.

This lane is separate from the published GitHub convenience Action. The Action is the simple way to run Amiss inside a repository workflow. The service is the stronger path: its webhook secret, App key, policy files, bootstrap, and state live outside the repository being checked.

Flow

The receiver accepts only the configured POST path with no query string. It bounds headers and body before admission and verifies GitHub’s HMAC over the exact body. A supported pull-request event is checked against the configured repository, target branch, and plan, then saved raw before the receiver returns 202 Accepted; the worker authenticates the saved bytes again before use. Other authenticated events return the same success without creating an inbox row.

provider_controlsgithubGitHubsigned pull-request eventtlsTLS terminatorgithub->tlsclaimdelivery recordclaimgatebounded receiver+ body HMACtls->gateinboxraw inboxordinary filesgate->inboxinbox->claimapi1GitHub App refresh+ strict effective rules+ test mergeclaim->api1api2final refreshfetchacquire exactrepository + actionapi1->fetchbootsupervised bootstrap+ sealed enginefetch->bootassessretain externalchainboot->assessassess->api2savesave exact resultapi2->savecheckApp-ownedCheck Runsave->checkdonemark delivery donecheck->done

The supported pull-request actions are opened, reopened, and synchronize. An edited event is accepted only when its signed changes.base.ref.from field records a base-branch change. Other edits and non-pull-request events do not create work. Classification uses the signed body, not the unsigned X-GitHub-Event header. Invalid JSON and malformed supported pull-request payloads still fail authentication.

Using an App installation token, the adapter refreshes the exact repository, pull request, base and candidate commits and trees, default branch, GitHub test merge, and effective rules for the configured protected branch. The test merge must be ready and mergeable, with the exact base and candidate parents and evaluated tree. The adapter acquires the repository and pinned action revision into private directories. The provider-neutral controller then runs the sealed bootstrap, retains the external chain when enabled, refreshes GitHub again, saves the exact result, and publishes it on that authoritative test-merge commit. A changed head or gate, closed pull request, removed authorization, timeout, missing output, or tampered runtime cannot turn the new evaluation into a pass.

The raw inbox is not the replay authority. It removes a row after the controller finishes. The separate FileLedger keeps the final delivery state and makes a repeated publication use the same evaluation and result.

GitHub App

Create a GitHub App owned by the account that controls the repository. Give it only these repository permissions:

PermissionAccessWhy
MetadataReadRead repository identity and effective rules.
ContentsReadFetch the exact repository and pinned action objects.
Pull requestsReadRefresh the authenticated pull request.
ActionsReadRead configured workflow runs and their semantic-evidence artifacts.
ChecksRead and writeRead and create the App-owned Check Run.
Commit statusesRead and writeMake the App available as the selected source when configuring the required status.

Subscribe the App to the pull_request and workflow_run webhook events, set a strong webhook secret, and grant the installation access to the configured repository. The pinned action repository must be on the same provider instance. If it is another private repository, that installation must be able to read it too. GHES operators must therefore mirror and pin the action on their GHES instance; the lane will not cross from GHES to github.com for runtime code. The service does not need repository Administration permission: it reads the effective rule and refuses when the expected rule is absent.

GitHub automatically subscribes Apps with Checks write access to check_run and check_suite events. The lane authenticates those deliveries and returns 202 without queueing work. It also returns no work for workflow runs outside the configured producer and for configured runs that did not succeed.

Create an active branch ruleset for the configured target branch. Enable strict required checks, so GitHub requires the pull request to be up to date with that branch. Add a required status check whose name exactly matches required_status_name in the execution constraint, and select this GitHub App as the expected source. GitHub documents why selecting an App matters: a required check from another person or integration must not satisfy that rule. The service reads the effective rules for the branch, including applicable organization rules. A missing rule, a non-strict rule, an “any source” rule, or a conflicting rule revokes authorization for that run.

Classic branch protection is not supported by this lane. Configure a ruleset even if the repository also has a classic protection rule.

Build and run

First prepare the execution constraint for the exact action commit, bootstrap, and required App-owned check name used by this lane.

Build the service. Its network dependencies belong to the provider crates and never enter the engine’s own closure:

cargo build --release --locked \
  -p amiss-controller-github-service --bin amiss-controller-github

Pre-create the private scratch, inbox, ledger, and artifact directories, then run the shared offline configuration check:

target/release/amiss-controller-github --check /etc/amiss/github.json

Start the service with the same absolute config path:

target/release/amiss-controller-github /etc/amiss/github.json

The listener is plain HTTP. Bind it to loopback or a private network and put a TLS terminator in front of it. The proxy must preserve the exact body and required GitHub headers; it must not decode, decompress, or rewrite the signed body. The service takes a configured delivery permit before reading a body and holds it through durable inbox admission, but it does not own the public connection budget. The proxy must still cap concurrent connections and apply total, header, body, idle, and slow-body deadlines. Forward the configured artifact prefix only to consumers who can present its bearer token. Keep the probes and metrics private, and use the shared service operation contract for readiness, redacted lifecycle events, counters, and graceful drain.

The delivery endpoint returns:

StatusMeaning
202The authenticated request is durable, was already saved, or requires no work.
400The request shape, path query, or stored delivery is invalid.
401Authentication failed.
403The signed event names another repository, target, or plan.
408The request body did not finish within 30 seconds.
409The same source identity was reused for different bytes.
413The body limit was crossed.
431The header count or byte limit was crossed.
503The service is unready, or trusted time, storage, capacity, or the worker is unavailable.

Configuration

Configuration is strict JSON: unknown and duplicate fields are errors. All file and directory paths are absolute. The four writable roots must already exist as separate real directories; none may contain another. The bootstrap must be a real file whose digest matches the loaded execution constraint.

{
  "listen": "127.0.0.1:8080",
  "webhook_path": "/webhooks/github",
  "github": {
    "instance": "github.com",
    "api_base": "https://api.github.com",
    "app_id": 12345,
    "installation_id": 67890,
    "private_key_file": "/etc/amiss/github-app.pem",
    "webhook_keys": [
      {
        "id": "current",
        "secret_file": "/etc/amiss/webhook.secret",
        "active_from_unix_millis": 1784764800000,
        "active_until_unix_millis": null
      }
    ]
  },
  "repository": {
    "id": 112233,
    "owner": "example",
    "name": "project",
    "target_branch": "main"
  },
  "plan": {
    "profile": "enforce",
    "external_policy": "advisory",
    "execution_constraint_file": "/etc/amiss/execution-constraint.json",
    "organization_floor_file": "/etc/amiss/organization-floor.json",
    "debt_snapshot_file": null,
    "waiver_bundle_file": null,
    "workflow_artifacts": [
      {
        "workflow_identity": "docs-evidence.yml",
        "event": "pull_request",
        "artifact_name": "amiss-semantic-evidence",
        "payload_file": "amiss/semantic-template.json",
        "archive_byte_limit": 33554432,
        "file_byte_limit": 16777216,
        "semantic": {
          "acquisition_identity": "github-docs-evidence",
          "producer_kind": "site-build",
          "producer_identity": "docs-site",
          "producer_version": "0.5.1",
          "context_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111"
        }
      }
    ]
  },
  "paths": {
    "bootstrap": "/opt/amiss/amiss-bootstrap",
    "scratch": "/var/lib/amiss/scratch",
    "inbox": "/var/lib/amiss/inbox",
    "ledger": "/var/lib/amiss/ledger",
    "artifacts": "/var/lib/amiss/artifacts"
  },
  "artifacts": {
    "base_url": "https://amiss.example/amiss/artifacts",
    "bearer_token_file": "/etc/amiss/artifact.token"
  }
}

Repository owner and name are lowercase. target_branch is one branch name such as main, not a full refs/heads/... value. It binds admission, the plan route, effective-rules lookup, and the protected target of every run.

The same installation client also exposes a provider-only finality read for a registered relation subject. The caller still selects the client through operator-owned credential routing. The client then requires that subject’s provider instance, installation, canonical repository, branch, and SHA-1 format to match before it reads the current commit and validates the returned tree. This read does not acquire repository contents, stage a relation result, or publish a check run.

workflow_artifacts defaults to an empty list. Each row becomes part of the plan digest and uses the configured provider and repository; neither is accepted again inside the row. The workflow identity is a workflow file name or numeric GitHub workflow ID. All rows use the same workflow and event, so one successful completion proves that every planned artifact can exist before evaluation starts. While this list is nonempty, the earlier pull-request webhook is authenticated no-work rather than an acquisition that races the build. The completion’s signed payload must name exactly one pull request and reproduce the configured base repository, installation, target branch, candidate SHA and ref. Exact redelivery is deduplicated; unrelated checks and workflows never enter the inbox. The case-sensitive artifact name, sole ZIP member, acquisition identity, producer contract, context digest, and both byte limits must reproduce the producer exactly. Archive and payload limits are positive and capped at 32 MiB and 16 MiB respectively. Acquisition requires exactly one completed successful run for the authenticated candidate SHA and exactly one unexpired artifact whose linked run and repository metadata, declared size, and SHA-256 digest all agree. The signed download URL receives no App credential and may redirect no further.

instance is github.com for GitHub.com. For GHES, use its lowercase host as instance and its REST root, normally https://github.example/api/v3, as api_base. The server must support the App, Actions workflow-run and artifact, rules-for-branch, pull-request, commit, and Check Run APIs used by this lane under the pinned GitHub API version. Its HTTPS certificate must chain to a CA trusted by the service’s Rust TLS clients; there is no insecure-TLS switch. The API URL must use HTTPS and the provider host; credentials, ports, query strings, and fragments are rejected.

This lane accepts exact SHA-1 object IDs and Git protocol v2. A GHES deployment must support both. The execution constraint’s action repository must name that same GHES instance.

The App private key, webhook secrets, execution constraint, and optional controls are loaded from bounded regular files when the service starts. Secret-file bytes are exact: an accidental trailing newline changes the webhook secret. A webhook key is active from its inclusive start through its exclusive end. Overlapping windows allow rotation; removing an old key revokes it.

The optional limits object has separate execution and queue sections:

{
  "limits": {
    "execution": {
      "api_request_millis": 20000,
      "git_request_seconds": 120,
      "bootstrap_seconds": 120,
      "artifact_retention_seconds": 604800,
      "artifact_records": 1000
    },
    "queue": {
      "max_concurrent_deliveries": 16,
      "inbox_records": 64,
      "retry_max_millis": 60000
    }
  }
}

Each omitted field uses its default:

SectionFieldsDefaults
executionbody_bytes, header_count, header_bytes2 MiB, 64, 32 KiB
executionqueue_age_seconds, future_skew_seconds86,400, 5
executionledger_lease_seconds, ledger_records60, 50,000
executionapi_connect_millis, api_read_millis, api_write_millis5,000, 15,000, 15,000
executionapi_request_millis, git_request_seconds20,000, 120
executionbootstrap_seconds, statement_validity_seconds120, 300
executionartifact_retention_seconds, artifact_records604,800, 1,000
executionartifact_bytes, artifact_record_bytes1 GiB, 64 MiB
queuemax_concurrent_deliveries16
queueinbox_lease_seconds, inbox_records600, 64
queueinbox_bytes, inbox_record_bytes128 MiB, 3 MiB
queueretry_min_millis, retry_max_millis, idle_poll_millis1,000, 60,000, 250

Limits are checked together at startup. In particular, one inbox record must hold a maximum request, the API operation deadline must fit inside the ledger lease, the bootstrap wall limit cannot exceed 120 seconds, future skew cannot exceed 300 seconds, and the idle poll cannot exceed five seconds. Concurrent deliveries must be between 1 and 64. Execution fields govern authentication, provider calls, Git acquisition, the delivery ledger, retained artifacts, and bootstrap. Queue fields govern only webhook admission, the durable raw inbox, and its worker.

Configuration cannot raise the shared hard ceilings: 8 MiB per request body, 128 headers, 32 KiB of aggregate header bytes, 100,000 ledger rows, 1,024 inbox rows, 128 MiB for the whole inbox, 16 MiB for one inbox row, 100,000 artifact records, 64 GiB of artifacts, 1 GiB per artifact record, 365 days of retention, and 64 concurrent admissions. HTTP phase and operation timeouts cannot exceed 30 seconds, Git acquisition cannot exceed 120 seconds, and the queue poll cannot exceed five seconds. Smaller values remain available for a tighter deployment. The full artifact contract and retrieval command are in Retained provider artifacts.

Provider API responses are capped at 8 MiB. Effective-rule and Check Run lists stop at ten pages of 100 rows, or 1,000 rows total. Oversized responses, inconsistent counts, malformed pages, and pagination that does not finish inside that bound fail closed.

Git acquisition has a second, fixed fail-closed budget:

Git resourceFixed limit
Pack bytes2 GiB
Declared objects2,000,000
One inflated stream or resolved object128 MiB
All inflated streams4 GiB
All resolved objects4 GiB
Delta depth128
Reference deltas (REF_DELTA)Rejected
Pack indexingOne thread

The client requires Git protocol v2 and asks for the authenticated SHA-1 commits directly, with no moving ref selection, tags, or local “have” negotiation. It receives a depth-one pack, checks the header and object count before allocating the entry table, validates the stream and delta shape while spooling it, then indexes the same bytes. For each repository or action fetch, one git_request_seconds deadline is shared by network requests, pack receipt, validation, and the final indexing acceptance check. A crossing or an unsupported pack form fails the run; it is not silently retried with a weaker Git path.

State and replay

All state stores use checksummed ordinary files with bounded rows or bytes and atomic replacement. There is no SQL server, embedded database, or schema migration service. Use private local filesystems; network and shared filesystems are unsupported. Anyone who can read or alter the App key, webhook secret, artifact token, control files, bootstrap, scratch root, inbox, ledger, artifact root, or TLS proxy is inside this lane’s trust boundary. GitHub, repository and organization administrators, App owners and key issuers, and configured ruleset bypass actors are also trusted. None of these actors is made atomic with the local file records.

Only one live process may own an inbox. It has fixed row, byte, and per-row caps. A full inbox returns 503 instead of dropping an accepted request. A crash leaves a claimed row available for a later retry; successful controller completion removes the raw body.

The ledger has its own fixed record cap. GitHub signs the body but no delivery timestamp, so a completed exact-body replay marker is permanent. Cleanup must not invent an age for it. Size ledger_records for the expected lifetime before creating the root: when it is full, new identities fail closed while saved work can finish. Changing its lease, cap, or replay window requires a new empty root. Changing the repository, installation, target branch, or plan also changes the service route; drain the old inbox before starting that route on another empty inbox.

The hard 100,000-row ceiling gives one webhook-secret trust period a finite delivery lifetime. Before it fills, stop the old route, replace the GitHub webhook secret, remove the old secret from the service key ring, and start a new route with empty inbox and ledger roots. Do not overlap the old secret with the empty ledger: a captured old delivery would authenticate without its old replay marker. Keep the stopped ledger as an audit record, but it no longer needs to serve replay checks once the old secret is permanently revoked. This cutover can miss an event, so leave the required check in place and trigger a fresh pull-request event after the new route is live.

Controller-plan, external-control, execution-constraint, bootstrap, repository, or target-branch changes need a required-check context rotation. Choose a new required_status_name and add it to the strict ruleset, still bound to this App, while the old required context remains. Start the new route on an empty inbox and prove that it can publish the new check. The overlap fails closed because both checks are required. Then remove the old required context and stop the old route. Preserve the existing ledger: its permanent rows are still the replay record for deliveries accepted under the old route. Do not reuse the old status name for a different plan merely because the local files changed.

Published evidence

The Check Run is attached to GitHub’s authoritative test-merge commit, not merely the pull request’s head commit. The evaluation ID becomes its external_id. Before reading or creating a run, the adapter fetches the pull request again. If its head, base, refs, or test-merge commit no longer matches the staged publication, the stale delivery completes without writing a Check Run. This keeps an older out-of-order delivery from cancelling or replacing evidence on the newer gate. Otherwise the adapter reads checks for that gate commit, required name, and App. It ignores historical rows with another external ID, reuses one exact current match, and fails closed on duplicate or conflicting current rows.

Crash recovery across artifact-feedback upgrades admits older projections only when every stable field matches. The summary may lack either the additive semantic-input pair alone or that pair together with the older assessment-artifact and external-assessment lines. The adapter reuses that already-created Check Run without rewriting it. Normal graceful drain avoids this state; every other mismatch still fails closed.

A claimed cross-repository relation destination uses the same exact reconciliation machinery but targets its configured subject candidate commit and relation status name, not a pull request or test-merge commit. aligned and resolved-drift publish success; introduced or pre-existing drift and an unproven comparison publish failure. The credential-free summary and its domain-separated external ID bind the relation, coordination, fence, target, verdict, and all four audit digests. The caller may append the durable destination acknowledgement only after GitHub accepts that exact run or the adapter finds one exact prior match.

Controller resultGitHub Check Run conclusion
Passsuccess
Blockfailure
Unavailablefailure
Superseded while its staged gate is still currentcancelled

The Check Run summary names the provider, repository, change, provider run, gate commit, refs, commits, trees, plan, execution constraint, report digest, authenticated artifact locator, and exclusive expiry. The native details URL remains unset because a browser click cannot carry the artifact bearer header; authorized clients retrieve the summary’s locator. Accepted semantic evidence adds the audit artifact’s digest and sibling locator. Completed external verification adds the assessment locator and its three verdict counts, while incomplete verification says so without counts. An unavailable result also carries one stable failure label such as timeout or tampered-runtime. Below those bindings the summary lists the report’s own grouped feedback: the fix, check, and existing counts, then up to ten items naming a target and an affected-place count, every repository-derived value rendered under the human-atom law so hostile path bytes never become markdown or workflow syntax. The digest stays the evidence; the listed lines are a courtesy projection of the same report, and the retained locator provides every row. Together with a strict active ruleset bound to this App, that Check Run is provider evidence for the configured branch.

GitHub’s create-Check-Run call has no transaction with the local ledger. If GitHub accepts a create but its reply is lost, the exact result remains staged and the worker retries. A later lookup normally finds and reuses the run, but an ambiguous response followed by a stale lookup can create a duplicate; once both are visible the adapter fails closed. This is retry reconciliation, not an atomic exactly-once claim.

Required checks are commit-scoped. If two pull-request numbers resolve to the identical GitHub test-merge commit, an App-owned green check with the same required name can satisfy both. The service also reacts to signed events; it does not continuously poll every old gate. A ruleset, authorization, policy, or credential change does not proactively revoke an already green commit without a new accepted event. Use the status-name rotation above for policy and trust changes, and treat repository and organization administrators, App owners, key issuers, and ruleset bypass actors as part of the trust boundary.

The engine report itself remains unchanged. It is canonical evidence of what the engine evaluated, but it is not signed by GitHub or the controller. status: "verified" on a control means the engine checked that control’s digest and identity bindings; it does not identify the caller. Sandbox assurance also remains self-asserted. There is no provider_verified report field. Consumers that need provider origin must inspect the App-owned Check Run and its ruleset, not treat copied report bytes as an attestation.

Last change: , commit: 0987dade

GitLab provider lane

The unpublished amiss-controller-gitlab-service crate serves one GitLab project, one pipeline execution policy, and one protected target branch. It supports GitLab 19.3 or newer with Ultimate. The minimum comes from enforced merge trains, which are generally available from 19.3. GitLab 19.2’s feature-flagged preview is not supported. An instance below that floor is refused where it first shows: its project response carries none of merge_pipelines_enabled, merge_trains_enabled, merge_trains_skip_train_allowed, or merge_train_enforcement, so the adapter cannot read the project at all.

This lane does not use a project webhook or write a commit status. An independently owned pipeline execution policy injects one job into the merge train. That job presents a short-lived GitLab OIDC token to the service and waits. Only an exact Amiss pass returns HTTP success, so the job’s own result is the provider evidence required by the train.

The provider adapter also has the narrow boundary needed by a future cross-repository relation lane. It can resolve this job’s live merge-train candidate as a relation head and bind one exact staged relation result back to the same project, candidate, and configured job name. This remains synchronous: it does not create a GitLab status, and no service loads or runs relation configuration yet.

Flow

The request body contains only the merge request’s project-local number, which GitLab calls its IID:

{"merge_request_iid": 42}

The bearer token, not that body, supplies the project, pipeline, job, runner, policy origin, exact train commit, issue time, and replay ID. The service requires an RS256 token under one configured key ID, issuer, and audience. It then binds the job_project_id, canonical job_project_path, pipeline_id, job_id, runner_id, runner_environment, sha, pipeline_source, job_source, and policy job_config claims. The pipeline source must be merge_request_event, and the job source must be pipeline_execution_policy.

gitlab_providerpolicyindependent pipelineexecution policytrainenforced merge train+ injected jobpolicy->trainfirstrefresh job, train,change + ruletlsTLS terminatortrain->tlsoidcOIDC claims+ bounded requesttls->oidcoidc->firstfetchacquire exactrepo + actionfirst->fetchfinalrefresh gateagainbootsealedbootstrapfetch->bootassessretain externalchainboot->assessassess->finalsavesave exactresultfinal->saveresult204 only for pass;policy job succeedssave->result

The first provider refresh reads the exact job, pipeline, merge-train car, merge request, project, target branch, protected-branch rules, and Git objects. The service requires the job, pipeline, and train to be running and to name the same train commit. It runs the sealed bootstrap only after that state and both acquired trees agree. External verification, when enabled, is retained before a second refresh performs the same checks and accepts the staged result.

GitLab project

Use a SHA-1 project on a root-mounted HTTPS GitLab instance. On gitlab.com a fresh account must pass identity verification before any hosted-runner job runs; until then every pipeline fails jobless with the verification banner as its only error. Configure the checked project as follows:

  • enable merged-results pipelines and merge trains;
  • set merge-train enforcement to Enforce for all users, including Owners and administrators;
  • require pipelines to succeed and do not count skipped pipelines as successful;
  • disable the option that lets a merge request skip the train;
  • use the merge merge method, set squash to never, and do not enable squash on the merge request; and
  • protect the target branch with direct push and force push disabled.

The service reads every protected-branch rule whose exact or wildcard name matches the target. At least one must match, and every match must report allow_force_push: false and one nonempty push-access entry with access level 0 and no user, group, deploy key, or member-role exception. A broader matching rule that restores a push path revokes the run.

The merge method is part of the tree proof. The train result must have exactly two parents: the target or previous train car first, and this merge request’s source commit second. Fast-forward, semi-linear, rebased, or squashed train shapes are not supported.

These requirements follow GitLab’s merge-train enforcement and protected-branch contracts. The adapter checks the live API response rather than trusting the service configuration to describe it.

Pipeline execution policy

Keep the security policy project and included CI configuration outside the checked project and under the operator who owns this lane. Use an enabled pipeline execution policy whose complete shape is:

pipeline_execution_policy:
  - name: Amiss documentation gate
    description: Runs Amiss on every merge-train result
    enabled: true
    pipeline_config_strategy: inject_policy
    content:
      include:
        - project: security/amiss-policy-job
          file: policy-ci.yml
          ref: eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
    policy_scope:
      projects:
        including:
          - id: 101
    suffix: never
    skip_ci:
      allowed: false
    no_pipeline:
      allowed: false
    variables_override:
      allowed: false
      exceptions: []
      dotenv: respect_policy

The content.include.ref must be an immutable 40-character commit, not a branch or tag. This closes a separate movement path: GitLab’s OIDC job_config.url and job_config.sha identify the security-policy YAML, while the included CI file contains the job itself. Pinning the include makes the policy commit bind both. suffix: never makes a duplicate project job fail instead of being renamed. Skip and no-pipeline allowances remain false, and no variable or dotenv exception may alter the job. Scope the policy to the exact checked project.

The included job requests one OIDC ID token with the exact audience configured in the service. Its only security decision is the service response. A minimal shape is:

amiss:policy:
  stage: .pipeline-policy-post
  image: registry.example/security/http-client@sha256:<reviewed-image-digest>
  allow_failure: false
  id_tokens:
    AMISS_ID_TOKEN:
      aud: https://amiss.example/gitlab/policy/evaluate
  variables:
    GIT_STRATEGY: none
  script:
    - 'test "$(curl --fail-with-body --silent --show-error --output /dev/null --write-out %{http_code} --header "Authorization: Bearer ${AMISS_ID_TOKEN}" --header "Content-Type: application/json" --data "{\"merge_request_iid\":${CI_MERGE_REQUEST_IID}}" https://amiss.example/gitlab/policy/evaluate)" = 204'
  rules:
    - if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_EVENT_TYPE == "merge_train"'
    - when: never

Keep the script on one physical line. Policy injection carries the included file’s newlines into the merged configuration literally, so a wrapped scalar runs each fragment as its own command and the first live train taught this the hard way.

Replace the image and endpoint placeholders with operator-owned, immutable values. Do not add a fallback command, an alternate success path, or a project-controlled variable that can change the endpoint, audience, headers, body, job name, or rules. The service also pins the exact job_config.url and SHA reported in the OIDC token, so a job copied into the checked repository does not authenticate as the policy job. The event-type test matters: detached and merged-result merge-request pipelines also report merge_request_event, but only a merge-train pipeline reports CI_MERGE_REQUEST_EVENT_TYPE=merge_train. The explicit status test rejects every response other than 204, including a successful-looking redirect, proxy page, or alternate 2xx response.

The job needs no GitLab API or repository credential. Its OIDC token is short-lived and specific to that job. The controller keeps its API and Git credentials outside the pipeline.

Credentials and OIDC keys

Use separate controller-owned credentials:

  • an API token with read access to the configured project, jobs, pipelines, merge trains, merge requests, branches, commits, and protected-branch settings; and
  • an HTTPS Git credential with read access to both the checked project and the pinned action repository.

Store each token as exact bytes in a private regular file. A trailing newline is part of the token and makes the configuration invalid. The Git username is explicit; for a personal, project, or group access token it is commonly oauth2, but use the value required by the chosen GitLab credential.

The service does not fetch OIDC keys at runtime. Export the instance’s current RSA signing keys from its JWKS, review them, and pin each public key in a private file with its exact kid and a local anchor name. One through sixteen unique keys are accepted. The issuer must be the configured GitLab HTTPS instance, and the API root must be exactly /api/v4.

For key rotation, add the new key beside the old key and restart the service before GitLab starts using it. Keep the old key until every job token it signed has expired and any in-flight request has finished, then remove it and restart again. Removing a key revokes requests signed only by that key.

The policy also names trusted runners. Enable GitLab-hosted runners only when they are part of the deployment’s trust boundary. Otherwise list the exact positive self-hosted runner IDs. A generic “self-hosted” claim without a listed ID is rejected.

Build and run

First prepare the execution constraint for the exact action commit, bootstrap, and stable controller result name used by this lane.

Build the service from source:

cargo build --release --locked \
  -p amiss-controller-gitlab-service --bin amiss-controller-gitlab

Pre-create the private scratch, ledger, and artifact directories, then run the shared offline configuration check:

target/release/amiss-controller-gitlab --check /etc/amiss/gitlab.json

Start the service with the same absolute config path:

target/release/amiss-controller-gitlab /etc/amiss/gitlab.json

Before binding the listener, the service opens and validates both state roots, migrates and cleans the ledger, and removes expired artifacts. Each admitted policy job then gets a fresh fenced owner session from the prepared ledger root. A normal request does not repeat full-ledger maintenance, so separate evaluations can remain concurrent. After startup, the service runs ledger cleanup once per minute outside request handling; artifact operations enforce expiry themselves. Ledger scans use a blocking worker, skip missed intervals, and never overlap. If a scan or worker fails, the service stops instead of continuing with unvalidated ledger state. Stop every v0.9 controller process before the first upgraded open; the ledger metadata upgrade is one-way.

The service listens on plain HTTP. Bind it to loopback or a private network and put an operator-controlled TLS terminator in front. The proxy must preserve the Authorization header and exact body and must cap connections plus total, header, body, idle, and slow-body time. Set the policy job timeout above the service’s API, Git, and bootstrap deadlines. Forward the configured artifact prefix with its separate bearer token. Keep the probes and metrics private, and use the shared service operation contract for readiness, redacted lifecycle events, counters, and graceful drain.

max_concurrent_evaluations is an in-process cap from 1 through 64. The service takes a permit after validating headers and before reading the body, then holds it through the complete blocking evaluation. Capacity exhaustion returns 503; the proxy’s connection cap is still required.

Configuration

Configuration is strict JSON. Unknown and duplicate fields are errors. All file and directory paths are absolute. The scratch, ledger, and artifact roots must already exist as separate real directories outside the repository and action trees, and the bootstrap must match the loaded execution constraint.

{
  "listen": "127.0.0.1:8080",
  "evaluation_path": "/gitlab/policy/evaluate",
  "max_concurrent_evaluations": 4,
  "gitlab": {
    "instance": "gitlab.example",
    "api_base": "https://gitlab.example/api/v4",
    "api_token_file": "/etc/amiss/gitlab-api.token",
    "git": {
      "username": "oauth2",
      "token_file": "/etc/amiss/gitlab-git.token"
    },
    "oidc": {
      "issuer": "https://gitlab.example",
      "audience": "https://amiss.example/gitlab/policy/evaluate",
      "trust_set": "gitlab-oidc",
      "keys": [
        {
          "kid": "current",
          "anchor": "gitlab-key/current",
          "public_key_file": "/etc/amiss/gitlab-oidc-current.pem"
        }
      ]
    }
  },
  "policy": {
    "integration": "pipeline-execution-policy/1",
    "project_id": 101,
    "project_path": "acme/widget",
    "target_branch": "main",
    "job_name": "amiss:policy",
    "config_url": "https://gitlab.example/security/policies/-/blob/ffffffffffffffffffffffffffffffffffffffff/.gitlab/security-policies/policy.yml",
    "config_commit": "ffffffffffffffffffffffffffffffffffffffff",
    "gitlab_hosted_runners": true,
    "self_hosted_runner_ids": []
  },
  "plan": {
    "profile": "enforce",
    "external_policy": "advisory",
    "execution_constraint_file": "/etc/amiss/execution-constraint.json",
    "organization_floor_file": "/etc/amiss/organization-floor.json",
    "debt_snapshot_file": null,
    "waiver_bundle_file": null
  },
  "paths": {
    "bootstrap": "/opt/amiss/amiss-bootstrap",
    "scratch": "/var/lib/amiss/scratch",
    "ledger": "/var/lib/amiss/ledger",
    "artifacts": "/var/lib/amiss/artifacts"
  },
  "artifacts": {
    "base_url": "https://amiss.example/amiss/artifacts",
    "bearer_token_file": "/etc/amiss/artifact.token"
  }
}

project_path is the lowercase path with its complete nested group prefix. target_branch is one branch name, not a full ref. job_name is the exact live GitLab job name. config_url and config_commit must reproduce the policy job’s job_config OIDC claims exactly. config_url is GitLab’s blob URL for the security-policy YAML, not the included CI file; copy the exact claim rather than assembling the URL by hand. integration is a controller identity for this policy binding; change it when the policy trust boundary changes.

Only a root-mounted HTTPS instance without an explicit port is supported. The API root may end in /api/v4 or /api/v4/; credentials, alternate paths, query strings, fragments, redirects, and insecure TLS are rejected. The checked project and action repository must both use that instance and SHA-1.

The optional limits object overrides execution defaults:

FieldsDefaults
body_bytes, header_count, header_bytes2 MiB, 64, 32 KiB
queue_age_seconds, future_skew_seconds86,400, 5
ledger_lease_seconds, ledger_records60, 50,000
api_connect_millis, api_read_millis, api_write_millis5,000, 15,000, 15,000
api_request_millis, git_request_seconds20,000, 120
bootstrap_seconds, statement_validity_seconds120, 300
artifact_retention_seconds, artifact_records604,800, 1,000
artifact_bytes, artifact_record_bytes1 GiB, 64 MiB

queue_age_seconds remains part of the authenticated replay window; it does not create a raw request queue for this synchronous lane. The GitLab endpoint further clamps the effective body to 1 KiB and the header count to 32; a smaller configured value still wins. Future skew cannot exceed 300 seconds, the API request deadline must fit inside the ledger lease, and the bootstrap limit cannot exceed 120 seconds. Provider responses share one 4 MiB budget per refresh. Git acquisition uses the fixed pack, object, inflated-byte, resolved-byte, delta-depth, and one-thread indexing limits listed in the GitHub lane. Protected-branch lookup stops at ten pages of 100 rows; an unfinished or oversized list fails closed.

The underlying shared ceilings are 8 MiB per request body, 128 headers, 32 KiB of aggregate header bytes, 100,000 ledger rows, 100,000 artifact records, 64 GiB of artifacts, 1 GiB per artifact record, 365 days of retention, and 64 concurrent evaluations; GitLab’s smaller endpoint clamps win where they overlap. HTTP phase and operation timeouts cannot exceed 30 seconds, and Git acquisition cannot exceed 120 seconds. See Retained provider artifacts for the exact retrieval and lifecycle contract.

HTTP result and replay

The endpoint returns:

StatusMeaning
204The exact result was saved, the final refresh still matched, and the conclusion was pass.
400The configured endpoint was called with a query string.
401The merge-request hint or OIDC bearer token, signature, key, time, or required claims were invalid.
403The authenticated request did not select the configured provider route or check plan.
408The request body did not finish within 30 seconds.
412The controller completed without an exact published pass, including block, unavailable, stale, busy, or duplicate work.
413The body limit was crossed.
431The header count or byte limit was crossed.
503The service is unready, or capacity, trusted time, storage, provider access, acquisition, or evaluation was unavailable.

A completed result with a still-live artifact also returns Link: <...>; rel="amiss-report", X-Amiss-Artifact-Auth: bearer, and X-Amiss-Artifact-Expires-Unix-Millis: <instant>. A completed result with retained semantic inputs adds an amiss-semantic-input Link target and X-Amiss-Semantic-Input-Digest. A completed external assessment adds an amiss-assessment Link target, its digest, and refuted, unproven, and reachable count headers; an incomplete one carries only the incomplete state. These headers appear with X-Amiss-Report-Digest on both 204 and completed 412 responses; only the status decides the policy job. Retrieve each advertised Link target with the separately configured artifact bearer token and recompute its SHA-256 digest.

The service checks bounds, OIDC, and the configured plan before creating an owner session, touching a delivery row, or starting API, Git, or runner work. The policy job must treat only 204 as success. Do not turn any other response into a warning or retry it inside the same script. A GitLab job retry receives a new job and token; the adapter will bind that new run independently if the merge-train car is still active.

There is no raw webhook inbox. The synchronous request remains open while the controller uses its ordinary-file ledger, acquires both repositories, runs the bootstrap, and performs the final refresh. The OIDC jti, runner ID, and authenticated issue time form a bounded replay identity. Completed rows remain through their inclusive replay end and can then be removed by ledger cleanup. A clock rollback cannot reopen an expired row.

New-row admission reads only the capacity frame and exact row path. A full root returns 503; request handling never turns that rejection into a full-root scan. Startup and periodic maintenance remove bounded rows after their replay lifetime ends, freeing their slots without a service restart.

The final “publication” step makes no GitLab API write. It retains the exact report and external chain, refreshes the same job and gate one last time, stages that result and locator in the ledger, and lets the endpoint status decide the already running policy job. The local record and HTTP response are not one transaction. If the service completed but the 204 reply was lost, replaying the same token and request does not invent a second success; it fails closed as a duplicate and returns the same still-live artifact locator.

For a policy, plan, bootstrap, control, project, target, job, runner, or action change, update the independently owned policy configuration, pin its new config_commit, choose a new integration identity, and restart the service with the matching files. Existing train jobs from the old policy commit are rejected. Preserve the ledger until its bounded replay rows have expired.

What the job proves

A successful job proves that the independently owned policy supplied the configured job, GitLab signed its exact job and train claims, the live project still enforced the required merge path, and the service accepted an Amiss pass for that exact train-result tree. The API token, Git credential, OIDC keys, policy project, runner set, protected-branch administrators, GitLab instance, service host, TLS boundary, bootstrap, controls, scratch root, and ledger root remain inside the trust boundary. The artifact token and root are trust inputs too.

The engine report remains unchanged and self-asserted. It has no provider signature or provider_verified field. GitLab origin lives in the protected policy job and enforced merge train, not in copied report bytes.

Last change: , commit: 0987dade

Gitea and Forgejo provider lane

The unpublished amiss-controller-gitea-service crate serves one repository, one dedicated reviewer account, and one protected target branch. The same data-shaped adapter supports Gitea 1.27 or newer and Forgejo 16 or newer. It does not infer a forge from HTTP headers: the operator sets the provider namespace, and live API capabilities decide which supported protection shape is present.

This lane uses a review because Gitea-family status contexts are not bound to the identity that wrote them. A protected branch can require an approval from one named account, so the service owns that reviewer account and writes the final review itself.

Flow

The receiver accepts only the configured POST path with no query string. It bounds headers and body, takes a configured delivery permit before reading the body and holds it through durable admission, requires one agreed family signature value, verifies lowercase HMAC-SHA256 over the untouched body, binds the configured repository and target branch, and saves the raw request before returning 202 Accepted. The worker verifies the saved bytes again.

gitea_providerforgesigned pull-requesteventtlsTLS terminatorforge->tlsfirstrefresh PR,reviewer + ruleinboxauthenticate+ raw inboxtls->inboxinbox->firstfetchacquire exactrepo + actionfirst->fetchfinalrefresh gateagainbootsealedbootstrapfetch->bootassessretain externalchainboot->assessassess->finalsavesave exactresultfinal->savereviewdedicated reviewerapproval or rejectionsave->review

The source accepts opened, reopened, and synchronized pull-request actions. An edited event is accepted only when its signed change record says that the base ref changed. The unsigned delivery UUID is not trusted; replay identity is a domain-separated digest of the exact signed body.

Using the dedicated reviewer’s token, the adapter refreshes the token identity, repository, pull request, target branch, effective protection rule, exact commits, and existing reviews. Tree names come from the Git objects, not from the API: both families answer /git/commits/{sha} with the commit name in the commit’s tree field, and every tree route echoes whatever name it was given, so no route states the tree of a commit. The adapter fetches the two commits over HTTPS, reads each tree from the proven object, and refuses a REST body whose parents disagree with it.

It requires the event head to remain current, the pull request to be open, and the head to be based on the current target. A pull request Gitea reports as unmergeable is an unsettled provider state rather than a verdict, because Gitea reports mergeable: false for the second or two it spends recomputing a merge after a push and offers no separate “computing” signal. The lane retries such a refresh instead of publishing on it, so a pull request that stays unmergeable receives no review at all and cannot merge. It then acquires exact SHA-1 objects, runs the sealed bootstrap, retains the external chain when enabled, refreshes everything again, saves the result, and posts or reuses one exact review.

Controller resultReview
PassAPPROVED
BlockREQUEST_CHANGES
UnavailableREQUEST_CHANGES
Stale or closed before publicationNo new review

The review body binds the evaluation, conclusion, provider, repository, pull request, provider run, refs, commits, trees, plan, execution constraint, report digest, authenticated artifact locator, and exclusive expiry. Accepted semantic evidence adds the audit artifact’s digest and sibling locator. Completed external verification adds the assessment locator and its three verdict counts; incomplete verification says so without counts. Below those bindings it lists the report’s grouped feedback the way the GitHub summary does: counts, then up to ten items with atom-rendered targets; the locator provides every row. The required_status_name from the execution constraint is a readable review label and retry binding; the provider gate itself is the dedicated reviewer identity.

Crash recovery across artifact-feedback upgrades admits the same older additive projections as the GitHub lane, but only for an existing review whose stable fields still match. A newly created review must echo the complete current body exactly.

Dedicated reviewer

Create a separate restricted account for this lane. Give it administrator access to the checked repository and read access to the pinned action repository when that is separate. The account must be able to submit official pull-request reviews. Do not use a maintainer’s personal account or reuse one reviewer for another plan on the same protected branch.

Write access is not enough. On Gitea 1.27.0 and Forgejo 16.0.1, /repos/{owner}/{repo}/branch_protections/{rule} answers a write collaborator with 403, and the branch route leaves effective_branch_protection_name empty for anyone below administrator, so the lane cannot read the rule it is required to check. The protection rule below binds administrators too, so the reviewer gains no way to merge past its own verdict.

Create a scoped access token with the smallest instance-specific permissions that cover:

  • reading the current user;
  • reading the repository, branch protection, pull request, commits, and reviews;
  • cloning the checked and action repositories over HTTPS; and
  • creating a pull-request review and configured relation commit statuses.

On current Forgejo this means read:user and write:repository; the account’s repository access should supply the remaining boundary. Follow the equivalent current Gitea token scopes and Forgejo’s token-scope contract. Store the token as exact bytes in a private regular file. A trailing newline is part of the token and makes the configuration invalid.

The reviewer token, account recovery path, and any session able to act as that account are trust anchors. A human who can approve as the reviewer can satisfy the gate without Amiss.

Protected branch

An exact protection rule for the configured target branch is easiest to audit and is recommended. A wildcard rule is supported: the service reads the branch’s effective rule name, fetches that rule, and requires the two names to match. Keep overlapping wildcard priorities under the same operator control. The service refuses the run unless all of these common facts are true:

  • direct push is disabled;
  • push allowlists are disabled and empty;
  • no deploy key may push;
  • unprotected file patterns are empty;
  • exactly one approval is required;
  • approvals are restricted to exactly the dedicated reviewer, with no team;
  • rejected reviews block merging;
  • stale approvals are dismissed and not ignored;
  • an outdated pull request cannot merge;
  • administrators must follow the rule.

The empty unprotected-file rule matters. Gitea documents that such patterns permit direct pushes to selected files even when ordinary push is disabled. A missing, false, unknown, or contradictory protection capability fails closed.

The setup names follow the providers’ Gitea protected-branch and Forgejo branch-protection pages. The live API response, not the UI label, is what the adapter accepts.

The two supported API shapes close their remaining paths differently:

  • Gitea must report force push and bypass disabled, every related allowlist empty, no deploy key able to force-push, block_admin_merge_override: true, and repository allow_manual_merge: false.
  • Forgejo must report apply_to_admins: true. Forgejo 16’s official repository response omits allow_manual_merge and the Gitea-only force-push and bypass fields, so the adapter requires those fields to be absent rather than inventing values for them.

The adapter accepts exactly one complete shape and rejects mixed or contradictory fields. This is a capability check on live provider data, not a branch on a provider-name enum.

Webhook

Create a repository webhook for pull-request events:

  • method POST;
  • content type application/json;
  • a strong random secret;
  • the configured service URL; and
  • the native Gitea or Forgejo webhook type.

Gitea sends X-Gitea-Signature. Forgejo sends X-Forgejo-Signature and X-Gitea-Signature together, carrying one value under both spellings. Both are lowercase hexadecimal HMAC-SHA256 over the raw body. The service reads whichever of the two names the request states and accepts them when they agree, since agreeing spellings are one claim. Spellings that disagree, and one name stated twice, are ambiguous and are rejected. The TLS proxy must not decode, decompress, trim, or rewrite the body.

See the native Gitea and Forgejo webhook contracts for provider-side delivery setup.

Neither signature carries a trusted creation time. A completed exact-body replay marker is therefore permanent. Removing it would reopen a signed request that remains valid.

Build and run

First prepare the execution constraint for the exact action commit, bootstrap, and review label used by this lane.

Build the service from source:

cargo build --release --locked \
  -p amiss-controller-gitea-service --bin amiss-controller-gitea

Pre-create the private scratch, inbox, ledger, and artifact directories, then run the shared offline configuration check:

target/release/amiss-controller-gitea --check /etc/amiss/gitea.json

Start the service with the same absolute config path:

target/release/amiss-controller-gitea /etc/amiss/gitea.json

Bind the plain HTTP listener to loopback or a private network and put the bounded TLS edge described in Provider-verified controls in front. Forward the configured artifact prefix only with its bearer authentication. Keep the probes and metrics private, and use the shared service operation contract for readiness, redacted lifecycle events, counters, and graceful drain.

The delivery endpoint returns:

StatusMeaning
202The authenticated raw request is durable, or the exact request was already saved.
400The request shape, path query, or stored delivery is invalid.
401Authentication failed.
403The signed event names another repository, target, or plan.
408The request body did not finish within 30 seconds.
409The same source identity was reused for different bytes.
413The body limit was crossed.
431The header count or byte limit was crossed.
503The service is unready, or trusted time, storage, capacity, or the worker is unavailable.

Configuration

Configuration is strict JSON. Unknown and duplicate fields are errors. All file and directory paths are absolute, and the writable roots must be separate real directories outside the repository and action trees.

{
  "listen": "127.0.0.1:8080",
  "webhook_path": "/webhooks/gitea",
  "provider": {
    "namespace": "gitea",
    "instance": "forge.example",
    "api_base": "https://forge.example/api/v1",
    "reviewer": {
      "id": 77,
      "login": "amiss-controller",
      "token_file": "/etc/amiss/reviewer.token"
    },
    "webhook_keys": [
      {
        "id": "current",
        "secret_file": "/etc/amiss/webhook.secret",
        "active_from_unix_millis": 1784764800000,
        "active_until_unix_millis": null
      }
    ]
  },
  "repository": {
    "id": 101,
    "owner": "example",
    "name": "project",
    "target_branch": "main"
  },
  "plan": {
    "profile": "enforce",
    "external_policy": "advisory",
    "execution_constraint_file": "/etc/amiss/execution-constraint.json",
    "organization_floor_file": "/etc/amiss/organization-floor.json",
    "debt_snapshot_file": null,
    "waiver_bundle_file": null
  },
  "paths": {
    "bootstrap": "/opt/amiss/amiss-bootstrap",
    "scratch": "/var/lib/amiss/scratch",
    "inbox": "/var/lib/amiss/inbox",
    "ledger": "/var/lib/amiss/ledger",
    "artifacts": "/var/lib/amiss/artifacts"
  },
  "artifacts": {
    "base_url": "https://amiss.example/amiss/artifacts",
    "bearer_token_file": "/etc/amiss/artifact.token"
  }
}

Use namespace forgejo for Forgejo. Provider instance, repository owner, repository name, and reviewer login are lowercase canonical values. target_branch is one branch name such as main, not a full ref. Only root-mounted HTTPS instances are supported; ports, user information, query strings, fragments, alternate API roots, and insecure TLS are rejected.

The optional limits object has two strict sections:

{
  "limits": {
    "execution": {
      "api_request_millis": 20000,
      "git_request_seconds": 120,
      "bootstrap_seconds": 120
    },
    "queue": {
      "max_concurrent_deliveries": 16,
      "inbox_records": 64,
      "retry_max_millis": 60000
    }
  }
}

Omitted values use the defaults listed in the GitHub lane’s limit table. execution covers ingress, ledger, provider HTTP, Git, and bootstrap bounds. queue covers webhook concurrency, the raw inbox, retry, and polling. Execution limits also configure the artifact retention, record count, total bytes, and per-record bytes listed in the GitHub lane’s table. max_concurrent_deliveries must be between 1 and 64. The shared 8 MiB body, 128-header, 32 KiB aggregate-header, 100,000-ledger-row, 1,024-inbox-row, 128 MiB inbox, 16 MiB inbox-row, 100,000-artifact-record, 64 GiB artifact, 1 GiB artifact-record, 365-day retention, and 64-concurrent-delivery ceilings apply here too. See Retained provider artifacts for retrieval and lifecycle rules.

The action repository in the execution constraint must use the same provider host and SHA-1 object format. The reviewer’s token must be able to read it. The service requires Git protocol v2 and uses the fixed pack limits described in the GitHub lane.

State, replay, and rotation

The inbox, ledger, and artifact store use bounded checksummed files, not SQL or an embedded database. One process owns each inbox and artifact root. The worker removes raw bytes only after controller completion; the ledger retains the exact result and permanent body-replay marker, and the artifact store retains the exact report, optional semantic-input audit value, and optional external chain until the published expiry.

The hard 100,000-row ceiling gives one webhook-secret trust period a finite delivery lifetime. Before it fills, stop the old route, replace the provider webhook secret, remove the old secret from the service key ring, and start a new route with empty inbox and ledger roots. Never accept the old secret against that empty ledger: a captured old delivery would authenticate without its old replay marker. Keep the stopped ledger as an audit record, but it no longer needs to serve replay checks once the old secret is permanently revoked. The cutover can miss an event, so leave the reviewer requirement in place and trigger a fresh pull-request event after the new route is live.

Review creation is not atomic with the ledger. Before creating a review, the adapter refreshes the pull request and existing reviews. It reuses one exact current review and rejects a conflicting review carrying the same evaluation marker. If the provider accepted a create but its reply was lost, a later lookup normally finds the exact review; an ambiguous stale lookup can still create a duplicate, after which conflicting state fails closed.

Cross-repository relation publication has no pull request to review. The same client therefore uses GET and POST /repos/{owner}/{repo}/statuses/{sha} on the exact frozen commit. It reconciles the provider’s latest row for the configured context, accepts only the dedicated reviewer’s versioned Amiss marker, and requires an exact create response before the caller may acknowledge its durable destination. The marker is a digest of the complete credential-free relation projection; no credential or bearer artifact URL is exposed.

Before a relation is frozen for evaluation, the client can also resolve a configured subject branch through GET /repos/{owner}/{repo}/git/commits/{branch}. It first proves that the token still names the configured dedicated reviewer, then requires exact SHA-1 commit and tree names in the response. This only supplies a current-head fact; the service must still route the declared credential and the existing bounded Git transport must acquire and verify the frozen objects.

This does not turn a Gitea-family status into the dedicated-reviewer gate described above. Required status contexts are not bound to their writer, so another repository writer can imitate or replace one. Relation statuses are an honest native publication surface for an unchanged branch; an identity-bound destination must carry any protected cross-repository gate.

Gitea-family approval freshness is based on changed pull-request content. The service posts a review on the exact candidate commit and checks the exact commit and tree before publication, but the provider may continue to count an approval across a commit-only rewrite with the same diff. The honest claim is therefore an exact-tree gate, not provider-enforced exact-commit freshness.

For ordinary webhook-secret rotation on the same ledger, overlap key windows, then remove the old key on restart. For a plan, bootstrap, control, repository, or target change, use a new dedicated reviewer account and a new empty inbox and ledger route. The visible review label alone cannot separate two plans because branch protection binds the account, not the label. Preserve the old ledger while its old webhook secret remains accepted; after permanent revocation, retain it only for audit.

Last change: , commit: 0987dade

Controller delivery

Provider deliveries are repeated, workers overlap, and a process can stop at any point around publication. The controller therefore needs a small durable record that answers two questions: who may evaluate this delivery now, and which exact result must a retry publish?

In code, DeliveryLedger is the coordination interface for that record. It is a behavior contract rather than a required storage format. FileLedger is its first durable implementation; it uses ordinary files, not SQL or a database. This record is separate from the scan ledger, which records project research, and from the repository-owned review memory rejected in Provenance. The scanner itself remains offline and stateless.

Before the record

The contract takes each route from controller-owned configuration, never from the request body. It fixes the provider instance, the accepted trust-anchor set, and its signed-time rule. Before an adapter sees a delivery, IngressPolicy caps the exact body, header count, and total header bytes and checks the controller-recorded receipt time against a short queue window. Only an IngressCheck that passed those checks can enter ProviderAdapter::authenticate.

Each verifier consumes the IngressCheck itself. Authentication returns the provider facts and a small proof: which configured anchor matched, which trust set it belonged to, an optional signed issue time, and the replay identity. That proof also binds the controller-selected route, receipt time, exact header sequence, and exact body. Its fields are private; an adapter can join decoded provider facts to a successful proof, but cannot relabel its trust set, remove its signed time, or move it to another request. The controller checks that binding, applies the route’s signed-time rule, chooses the replay lifetime, and creates the delivery key. None of those steps trusts a decoded body field before the signature succeeds.

The replay key depends on what the provider actually signs:

Provider inputWhat is authenticatedReplay keyTime rule
GitHub X-Hub-Signature-256HMAC-SHA256 over the exact bodyDomain-separated digest of the exact bodyReplay-only; there is no signed delivery-attempt timestamp header.
Gitea-family X-Gitea-Signature or X-Forgejo-SignatureHMAC-SHA256 over the exact bodyDomain-separated digest of the exact bodyReplay-only; there is no signed delivery-attempt timestamp header.
GitLab Standard WebhooksHMAC-SHA256 over webhook-id.webhook-timestamp.bodySigned webhook-idSignedTimePolicy::Required(max_age); replay-only is not a valid GitLab route.
GitLab policy-job OIDCRS256 token with exact issuer, audience, policy origin, project, job, pipeline, runner, train commit, issue time, and jtiDomain-separated runner ID and jti digestRequired signed age plus token nbf and exp; replay-only is invalid.

This follows the providers’ published contracts: GitHub signs the payload body, Gitea signs the raw body, and GitLab’s signing token follows Standard Webhooks. The matching library code is split into the GitHub, Gitea-family, and GitLab verifiers. GitLab’s legacy plaintext X-Gitlab-Token is deliberately unsupported. GitLabWebhook authenticates the timestamp but does not choose the route policy. Ingress rejects that proof under a replay-only route, so the signed timestamp cannot be silently discarded. A GitHub or Gitea delivery header is useful for logs, but using it as the durable key would let a captured signed body bypass replay protection by changing an unsigned header.

The supported GitLab lane instead uses GitLab’s OIDC ID-token contract through the GitLabOidc verifier. It pins reviewed RSA keys and binds the small merge-request hint only after the token claims authenticate it. The Standard Webhook verifier remains available as an independent library surface, not as this lane’s request path.

WebhookKeyring holds one through eight HMAC keys in zeroizing, redacted memory. Anchor IDs and secret bytes must be unique. The ring owns the trust-set ID carried into its proof, so an adapter does not relabel a successful match by hand. Each key has an inclusive start and exclusive end time selected against controller-owned receipt time. Overlapping windows permit rotation; removing an anchor revokes it. GitLab whsec_ tokens have a strict, secret-safe constructor. Exact-body replay IDs do not include the matching anchor, so rotating a key cannot turn the same signed delivery into new work.

The controller also fixes one ReplayWindow: the largest signed age any route may accept and the largest ingress queue age. A route may require a shorter signed age but cannot exceed that fixed ceiling. An authenticated message ID and signed issue time receive an inclusive replay end computed from the issue time plus both fixed ceilings. Exact-body and other replay-only requests are marked permanent because they carry no authenticated time from which safe deletion can be derived. This choice reaches the ledger as part of AcceptedDelivery rather than being derived from payload fields, and the file record rejects a bounded delivery from a different replay window.

These verifiers establish webhook origin and integrity, not current authorization or an exact repository snapshot. The GitHub and Gitea-family lanes add signed pull-request decoding, controller-owned refresh, merge-rule checks, acquisition, and provider publication. The GitLab lane uses a separately verified OIDC token from its protected policy job instead of the Standard Webhook verifier. Its authenticated claims enter the same delivery and replay contract. Deployment and provider-specific trust rules live in Provider-verified controls.

The full flow

The controller owns the provider route. The selected adapter authenticates the untouched headers and body before any body field is trusted. Only the resulting authenticated delivery reaches the durable record.

controller_deliveryrawraw deliverygatebounds + receipt timeraw->gatefirstfirst refresh+ renewauthauthenticate+ replay identitygate->authclaimclaimauth->claimclaim->firstpublishpublishclaim->publishsaved retrycheck bindingrunrun if active+ heartbeat+ renewfirst->runstagesave exact resultexternalretain externalchain + renewrun->externalsecondfinal refresh+ renewexternal->secondsecond->stagestage->publishcompletemark donepublish->complete

The first refresh resolves the event-bound provider run, not the change’s latest head. It supplies the exact repository, URL dialect, refs, commits, and trees given to the runner, plus the provider gate revision to which publication is bound. GitHub and GitLab enforce their result on that commit. A Gitea-family lane publishes an exact-commit review, while the provider owns how that review affects merging. When the plan enables external verification, the controller retains its exact plan, provider evidence, and assessment after the run. The second refresh then checks the same identity, gate revision, and current authorization before the result is staged. If the change was closed, revoked, or superseded, the controller may publish that fail-closed status; it never publishes an old pass or block as if it were still current. A provider adapter may complete a stale publication without an external write only after independently proving that its staged provider gate is no longer current.

The logical record

This is the logical schema required by the contract. It is not a file format, wire schema, or prescription for how bytes are stored.

PartLogical valueRule
Delivery keyProvider namespace, provider instance, integration ID, delivery IDNames one authenticated provider delivery.
Fixed bindingRepository, change, provider run ID and attempt, object format, event candidate commitReusing the key with a different binding fails before refresh, run, or publication.
Replay lifetimePermanent, or an inclusive replay end based on authenticated timeDecided by trusted ingress and stored with the fixed binding. Only an ended bounded lifetime can permit deletion.
Evaluation IDOpaque controller-created ID with fresh random bytesCreated on the first claim and kept through retries and reclaims. A later row cannot reuse it.
Temporary ownershipEvaluation ID, lease deadline, fenceGrants permission to evaluate; the record, not a worker’s clock, decides whether it is still live.
Saved resultEvaluation ID, check-plan binding, fence, provider run, full run identity, provider gate commit, conclusion, optional report and retained-artifact referenceFrozen as one exact value before provider I/O.
StateNew, running, result saved, doneEach change happens atomically: fully or not at all.

Here, “delivery ID” means the replay identity accepted by ingress. It is the signed message ID for a GitLab Standard Webhook, a domain-separated runner-and-jti identity for the supported GitLab OIDC job, or the controller’s digest of the exact signed body for GitHub and Gitea-family requests. It is never an unsigned convenience header.

How FileLedger lays this record onto disk, its fixed lock set, checksummed frames, and cleanup rules, is on The file ledger. The separate bounded store behind a saved artifact reference is described in Retained provider artifacts; it keeps evidence bytes but never decides this record’s state.

The public Rust boundary has four operations. This abridged excerpt omits documentation and type bounds; the ledger module is authoritative.

#![allow(unused)]
fn main() {
pub trait DeliveryLedger {
    type Error;

    fn claim(&mut self, delivery: &AcceptedDelivery)
        -> Result<DeliveryClaim, Self::Error>;
    fn renew(&mut self, delivery: &AcceptedDelivery, lease: &DeliveryLease)
        -> Result<LeaseRenewal, Self::Error>;
    fn stage(
        &mut self,
        delivery: &AcceptedDelivery,
        lease: &DeliveryLease,
        publication: &Publication,
    ) -> Result<StageOutcome, Self::Error>;
    fn complete(&mut self, delivery: &AcceptedDelivery, staged: &StagedPublication)
        -> Result<LeaseCompletion, Self::Error>;
}
}

claim is the one entry point for new work and retries:

ResultPlain meaningController action
ExecuteThis caller has a live lease.Refresh and evaluate.
PublishAn exact result was already saved.Check its binding, then publish it without refreshing or running again.
BusyAnother live claim currently owns the work.Return the evaluation ID and retry time; do no provider or runner work.
DuplicateThe saved result was published and marked done.Do nothing.
BindingConflictThe same delivery key was reused for different authenticated work.Reject it before any provider refresh, run, or publication.

FileLedger can also reject a new identity with Full, reject an already ended bounded delivery with Expired, or reject a root whose saved lease duration, cap, or replay window differs from the configured one. These are fail-closed admission results, not reasons to evict live, saved, or permanent replay rows.

Four states

The record has four logical states. A lease is temporary permission to run. Its fence is an always-increasing generation number: reclaiming expired work keeps the first evaluation ID but uses a higher fence.

delivery_statesfreshnewrunningrunninglease + fencefresh->runningclaim: Executerunning->runningrenew: same fenceexpired reclaim: higher fencesavedresult savedexact valuerunning->savedstagesaved->savedclaim: Publishpublish error: retrydonedonesaved->donepublish OKthen completedone->doneclaim: Duplicate

A claim against live work may return Busy without changing state. A different authenticated binding returns BindingConflict. A stale renewal or stage returns Lost; the controller does not turn uncertainty into ownership.

The stored deadline is a scheduling hint, not proof. Renewal must preserve the evaluation ID and fence and must not move that deadline backward. After each renewal, the controller subtracts its own current time and returns only the positive time left. The concrete runner renews before launch and halfway through each returned window, capped at five seconds between checks. A zero window or a lost, malformed, or uncheckable renewal returns Stop; the runner then cancels its ProcessKit tree and discards the output. The heartbeat boundary is cooperative, while this runner turns its refusal into process cancellation.

Provider refresh calls have no heartbeat. A concrete adapter must therefore give each refresh a timeout comfortably shorter than the lease window. The controller also renews after the runner returns; the final atomic stage remains the decisive stale-owner check.

Races and retries

Suppose one worker holds fence 7 and another tries to reclaim the expired work:

  • If reclaim wins, the record moves to fence 8. The first worker can no longer renew or save a result, so it makes no publication call.
  • If saving wins, the exact result is frozen under fence 7. Reclaim no longer grants an execution lease; every claim receives Publish until that value is marked done.

Saving happens before external provider I/O because the record and provider cannot share one transaction. Publication may therefore be attempted more than once after an error or ambiguous acknowledgement. The adapter must make publishing the same result again have the same effect, using the authenticated delivery and evaluation ID as its repeat-safe key. A different result under that key must fail closed.

That is the controller contract. GitHub’s create-Check-Run API does not offer an atomic transaction or caller idempotency key: an accepted create with a lost reply can be retried before the first run is visible and leave a duplicate. The concrete adapter reconciles one exact visible run and rejects visible duplicates. The provider-verified-controls phase record states that limit rather than implying exactly-once.

Stop pointWhat the next claim seesSafe next action
During a live runBusy, or Execute after expiryWait, or evaluate again with the same evaluation ID and a higher fence.
After saving, before publicationPublishPublish the exact saved value.
Provider accepted, but its reply was lostPublishRepeat the same provider update.
After publication, while completion is unclearPublish or DuplicateRepeat the same update if needed, then complete the exact saved value.
After completionDuplicateDo nothing.

complete accepts only the exact saved value and is repeatable for that value while its done marker exists. A completion error after the provider accepted an update is kept distinct from an error before publication. On retry, the record must expose either the saved value or the done state, never a new execution lease. Once cleanup safely removes an ended bounded marker, later completion is honestly Lost rather than guessed from missing evidence.

Failure behavior

ConditionRequired behavior
Raw ceilings, receipt time, signature, anchor window, route, trust set, or required signed request time failsReject the delivery before claiming it.
The key has a different authenticated bindingReject it before provider or runner work.
A saved result does not match the authenticated deliveryReject it before provider I/O.
Another claim is liveReport in progress and do no work.
Lease renewal cannot prove ownershipStop, discard runner output, and do not publish it.
A valid refresh for the same delivery reports closure or revocationSave and publish the matching unavailable result, never the old pass or block.
A valid refresh for the same delivery reports supersession or changes its URL dialect, refs, base commit, trees, or provider gate commitSave Superseded and invoke publication, which may prove the staged gate stale and write nothing; never publish the old pass or block.
A refresh returns another repository, change, object format, or event candidateReject it without saving or publishing a result.
Runner output is missing, timed out, too large, tampered with, or bound to the wrong identity or treeSave and publish the matching unavailable result without a report.
Atomic stage loses the fence raceMake no publication call.
Publication fails or its acknowledgement is unclearKeep the exact saved result for another publication attempt.
Completion cannot be confirmedReport a completion error; retry only the exact saved result.

The trusted runner promises that a completed engine result already passed its engine, exit-class, and request checks. The controller independently checks the returned identity, nonempty output, and size. It does not authenticate the engine report itself.

Supervised bootstrap run

run_bootstrap implements the provider-neutral execution step once exact repository and action trees have been acquired. It first reopens both repositories and verifies the requested commits and trees. It derives the sealed bootstrap job from the RunRequest and trusted instants rather than accepting a separately assembled job, so a caller cannot pair one run with another run’s control files. Trusted acquisition may return candidate-independent semantic-template bytes with the roots. Each result names one plan-frozen acquisition identity; job construction matches its producer and context, binds the exact candidate itself, combines its limits and ordering with plan templates, and fails closed on any defect. The job also carries one bounded canonical audit value with the exact acquired bytes, derived envelope bytes, acquisition identities, and digests for the artifact-retention layer. It also reads the selected bootstrap under a fixed byte ceiling and matches its digest to the frozen execution plan before copying it into a fresh private scratch directory.

The directory holds only the copied bootstrap, canonical request files, report, and final result record. The controller creates both output files and retains their open handles. The bootstrap may write through the fixed path names, but the controller never reopens those names; replacing a name therefore cannot replace the object that will be read. The child starts with a cleared environment and closed stdin, stdout, and stderr. The report is bounded by MACHINE_JSON_BYTES; the small result record is written last, so a missing record cannot be confused with a completed report. Exit status and that record are checked together. Missing output, oversized reports, timeout, and runtime tampering retain distinct fail-closed outcomes. Signals, heartbeat loss, and spawn failure all fail closed as Unavailable.

Supervision uses pinned ProcessKit 2.2.5 with Tokio through one cross-platform path. ProcessKit selects the host’s process-tree boundary. The controller enforces a positive wall limit no greater than 120 seconds and renews the ledger before launch and halfway through each newly proven lease window, capped at five seconds between checks. After every terminal path it hard-kills the group and waits up to two seconds for ProcessKit to report it empty before reading either output. Failure to prove that drain is unavailable, not completion. This also covers a clean leader exit, so a surviving descendant cannot escape merely by letting its parent finish. A heartbeat refusal cancels the same tree and discards its output. These bounds cover ordinary supervised execution; they do not promise recovery from a host kernel call that itself never returns.

Focused tests cover wrong commits and trees, a wrong bootstrap digest, the cleared child environment, timeout and heartbeat races, descendants that remain alive after their leader exits, path replacement, and missing, malformed, and oversized output. Every provider service calls this runner only after its adapter has refreshed provider state and acquired the exact roots.

What exists now

The controller crates contain the provider-neutral identities, bounded ingress gate, rotating key ring, signature verifiers, durable raw inbox, DeliveryLedger, FileLedger, bounded artifact store and retrieval route, worker, orchestrator, acquisition boundary, bounded Intersphinx and mdBook semantic-evidence producers, and supervised bootstrap runner. Focused tests cover ingress limits and tampering, replay, rotation and revocation, file corruption, cross-process ownership, reclaim, exact publication retry across restart, full roots, artifact expiry, clock rollback, runner timeout, process descendants, and output replacement. The acquisition result can also carry candidate-independent pre-scan semantic templates into the sealed controls request; candidate binding remains inside job construction. A trusted caller can derive source-bound routes, decoded anchors, and rendered navigation reachability from a completed mdBook build without executing mdBook in the controller; the built-in provider acquisitions currently leave that set empty.

Three merge-gate shapes join those pieces. GitHub uses a signed pull-request event, App refresh, strict App-bound ruleset, authoritative test merge, and App-owned Check Run. GitLab uses policy job OIDC, an enforced merge train, independently owned pipeline execution policy, and the policy job’s own result. Gitea and Forgejo use a signed pull-request event, effective protected-branch rule, dedicated reviewer identity, and that account’s approval or request for changes. All three acquire exact SHA-1 repository and action objects through the same fixed-budget protocol-v2 path, run the bootstrap, and refresh the provider gate again.

Provider-verified controls compares the lanes and links each deployment reference. The GitLab Standard Webhook verifier remains an independent library surface; the supported GitLab lane authenticates OIDC instead. The engine’s forge field still chooses only a URL dialect and never authenticates a provider.

Last change: , commit: 0987dade

The file ledger

FileLedgerRoot prepares the ordinary-file store, and each FileLedger is one independently fenced owner session over that root. Together they implement the delivery-record contract in Controller delivery. This page is their storage: what one root contains, which locks serialize it, and what cleanup may remove. The logical guarantees stay with the contract.

Layout and locks

FileLedger maps the authenticated delivery identity to a fixed lowercase digest. Provider text never becomes a path. One controller-owned root contains fixed metadata and locks plus bounded row files:

.amiss-root.state
.amiss-capacity.state
.amiss-maintenance.lock
.amiss-admission.lock
.amiss-clock.lock
.amiss-row-00.lock ... .amiss-row-ff.lock  (created only when used)
<delivery-key>.state
<delivery-key>.report                     (only while a result needs it)

The maintenance lock is shared by ordinary row work and exclusive during cleanup. The admission lock serializes capacity recovery, reservation, and creation of a new row. The clock lock serializes durable high-water updates. The first byte of the delivery digest selects one of 256 stable row-lock files; a shard collision may serialize unrelated rows but cannot let two processes win one transition. These fixed names avoid one permanent lock file per delivery.

Frames and replacement

Root metadata is itself a versioned, checksummed frame. It fixes the lease duration, maximum record count, and signed-age and queue ceilings for every process using that root, and stores the highest trusted controller time the ledger has seen. Opening the same root with a different lease, record cap, or replay window fails.

A separate checksummed capacity frame holds the record limit, a slot count that never understates use, and at most one pending row key. Before a new row is written, its slot and key are saved; after the row is written, the pending key is cleared. If the sequence is interrupted, the next new-row admission or full cleanup checks that exact row path and finishes the update. Before cleanup deletes a batch of ended rows, it saves one cleanup marker; after deletion it saves the exact count once. An interrupted batch leaves a safe upper bound and is reconciled by the next root open or explicit cleanup. Ordinary admission reads the bounded capacity frame and requested row; it does not walk the root directory. Once the cap is full, a new identity fails before its state file is created, while an existing row can still renew, save, publish, and complete. Operators must size the cap to include permanent replay markers.

Opening FileLedgerRoot validates the complete root, runs cleanup, and prepares the store once. Creating a session chooses a fresh owner identity without scanning or cleaning the root. FileLedger::open remains the convenience form of opening a root and immediately creating one session, so it still performs the startup scan.

The root metadata written by v0.9 is validated and upgraded in place, and its existing rows seed the first capacity frame. The migration changes root-level bookkeeping only; row and report bytes are unchanged, and the older row schema remains rejected. After the upgrade, a missing capacity frame or an unmarked count disagreement with the decoded rows is corruption. Stop every v0.9 controller process before the first upgraded open; the metadata upgrade is one-way.

The state file is a versioned, length-delimited, checksummed frame containing canonical JSON and is capped at 128 KiB. The reader accepts only its current row schema, v4. The v2 schema contains no check-plan binding, so it is rejected instead of attaching a caller-supplied policy to old work. The v3 schema spells the change a delivery is about, the provider run and the delivery itself as opaque strings; v4 stores them typed, as the pull request or merge request ids the provider issued, the run digest or pipeline job, and the provided id, exact-body digest or OIDC token, and no reader for the old spellings exists, so v3 rows are rejected too and a controller crossing that upgrade starts from an empty root. A schema change either carries every stored authorization field forward or rejects the old rows outright; nothing in between. A report is kept separately at one fixed path, bounded by the machine-report byte ceiling, while its digest and length remain in the saved state. Saving removes any dead report, writes and syncs the new report, then atomically replaces the state that names it. Completion first saves done, then removes the report. A stop between those steps can leave an unreferenced report, but cannot expose a saved state whose report was never written. Retrying completion and cleanup both remove that dead file.

The implementation uses Rust’s standard File::lock and the atomicwrites crate, leaving the operating-system calls behind those maintained boundaries. Replacement first syncs the new file. On Unix the crate replaces the destination and syncs its parent directory; on Windows it uses MoveFileExW with replace-existing and write-through flags. FileLedger therefore has one cross-platform contract on supported local filesystems: the current path contains either the old complete bytes or the new complete bytes. A stopped write may leave a temporary file, but cannot make partial bytes current.

The root must already exist as a real, private local directory outside the repository and action tree. FileLedger rejects a missing root or a root symlink. The service operator must own the directory and set its permissions or access-control list. Anyone who can read or change that directory is inside the controller trust boundary. The checksums detect damage, not a malicious writer. Shared and network filesystems are not supported.

Cleanup and replay

Malformed, oversized, non-regular, unknown-field, non-canonical, or digest-mismatched saved data fails closed, as does a missing report named by a saved state. Opening a root runs cleanup; creating an owner session does not. The same cleanup operation is public for later maintenance. Under the exclusive maintenance lock and the admission lock it validates the complete root and saved reports, settles a pending addition or marked batch cleanup, and otherwise requires the saved slot count to match the decoded rows. It then advances and saves the high-water clock before removing unreferenced reports, recognized atomic-write leftovers, and bounded done rows strictly after their inclusive replay end. It never removes running or saved work, even after that time, and never ages out a permanent done row. Unknown root entries and unsafe temporary-directory shapes fail closed instead of being deleted.

Saved stateCleanup rule
runningKeep it, even after a bounded replay end, because a worker may still own or reclaim it.
staged (result saved)Keep the state and its valid report until publication can finish.
done, permanentKeep the small state marker; it is the replay defense.
done, boundedKeep it through the inclusive replay end, then remove it.

Persisting the high-water clock before deletion means a local clock rollback cannot make an ended delivery look fresh. A claim for a bounded delivery whose row is gone but lifetime has ended returns Expired. Completion after deletion returns Lost, because the exact saved digest is gone; only a retained exact done marker can return repeat-safe Completed. A new record receives a fresh random evaluation suffix, so deletion cannot make a stale publication retry match a later row. Together, the record cap, fixed lock set, per-file ceilings, and one report path per row bound the named durable state. Known crash leftovers are removed on the next open or cleanup. Permanent replay rows deliberately consume capacity until an operator changes trust policy outside this record; cleanup must not guess an age for signatures that contain no trusted time.

Focused tests cover v0.9 migration, interrupted additions and batch cleanup, missing capacity or row state, and exact capacity after cleanup. The weekly non-gating run measures admission with 1,000, 10,000, 50,000, and 100,000 retained root entries, then records full-capacity rejection and full-cleanup cost separately.

Last change: , commit: 3f232531

Architecture

The engine has eight production crates, and trust flows in one direction; a ninth exists only for tests. The unpublished provider-controller crates share the same workspace and depend on the engine, never the other way round.

amisswireamiss-wireshared typed models,validation, machine contractsgitamiss-gitobject store, packs, index,no-follow handlesgit->wiremdamiss-mdpinned document parsersmd->wireadocamiss-adocAsciiDoc extractionadoc->wirerstamiss-rstreStructuredText extractionrst->wirescanamiss-scandiscovery, resolution,correlation, evaluation, policyscan->wirescan->gitscan->mdscan->adocscan->rstcliamissthe engine binarycli->wirecli->gitcli->scanbootamiss-bootstrapverified-run wrapperboot->wireboot->git

The graph above is the root workspace. amiss-wire owns the shared report, control, request, and evidence models and their validation rules. Serde reads and writes those types directly; serde_json_canonicalizer supplies RFC 8785 output, and RustCrypto supplies hashing and HMAC. Models derive their Serde implementations and use library attributes and adapters. Handwritten Serde implementations, visitors, field codecs, and forwarding serialization helpers are forbidden. Domain validation checks identities, digest bindings, sorted sets, and resource limits after decoding; it does not implement JSON syntax.

Readers now accept standard Serde JSON behavior. Named structs can also deserialize from positional arrays in declaration order. Known duplicate struct fields are rejected; ignored fields and maps follow Serde’s normal behavior, including last-value-wins map entries. Numbers are governed by the destination Rust type rather than a document-wide safe-integer rule. Typed integer fields still reject fractional values, and domain-specific numeric limits remain. The default serde_json recursion checks replace the former 512-container profile. Complete input readers still reject trailing data.

Generic models rely on Serde’s inferred trait bounds, without handwritten serde(bound) overrides. Ordinary Option<T> fields accept either omission or null as None. Their output still follows the model’s existing skip_serializing_if attributes. The relocation hint same_object_at retains three states (absent, explicit null, and a path) because they enter fact digests; its existing library adapter preserves those states.

GitHub, GitLab, and Gitea response models select only the fields Amiss uses and let Serde ignore the rest, including extra nested fields. Amiss-owned reports, requests, configuration, and stored records retain deny_unknown_fields where their contracts are closed.

This broadens accepted input without changing emitted object shapes or canonical examples. The published JSON Schemas describe the object representation. Canonical-byte comparisons and digest checks can still reject alternate representations at authenticated report and request boundaries; accepting a value through Serde does not establish its domain validity.

Domain values keep their types after admission. CLI adoption retains digests, owners, and instants; release staging retains repository identities, object formats, OIDs, artifact IDs, and repository paths. Scanner forge context carries a repository identity and optional branch refs, so an absent ref has no empty-string spelling. BranchRef names only refs/heads/; provider pull-request/train refs and private acquisition refs have different domains. The Git transport uses gix’s validated full-name type after enforcing its private ref namespace.

The controller owns provider identities and opaque IDs. Its AcquiredCommit carries typed commit, tree, and parent OIDs for the object resolver and GitLab/Gitea adapters. Wire-owned RequiredStatusName supplies the same existing grammar to execution constraints, controller checks, relation destinations, and provider configuration. Unrelated remote check names stay text so their presence cannot invalidate a response. Provider states and webhook actions use each provider’s own enums. Unknown strings remain representable; the consuming operation explicitly decides whether they are ignored, nonpassing, or invalid.

Persistent records reuse domain models and typed schema markers, IDs, digests, refs, and verdicts. The inbox serializes the production delivery model, using Serde library adapters for base64 bytes and its bare hexadecimal content digest. Artifact and ledger digests retain their sha256: spelling. Record field order, omission rules, and hash domains remain part of stored identity: the ledger’s repository projection deliberately retains its historical host/owner/name order. Generic sidecar audits carry the publication or relation verdict enum directly. Consumers take ownership when inputs are no longer needed, and validation inspects borrowed values. Inbox claims move decoded deliveries after persisting the lease; ledger replay moves metadata into publications without rebuilding objects for validation or comparison.

A type name alone does not establish validity. UtcInstant, OwnerId, and structured repository identities still require their existing domain validators after ordinary derived deserialization. Limits, timestamp ordering, object-format agreement, authorization, and identity binding remain separate checks. Parsed transport API bases use Url; evidence URLs retain their exact spelling because normalization could change a bound identity.

Open wire contracts are not narrowed to whichever values current producers happen to emit. External report modes and document strings, nonempty external checked_at evidence, rule IDs, site routes, locale page keys, and binary report paths retain their existing contracts. A stricter shared type for those fields requires agreement across their producers, schemas, and validators. Trusted-time run IDs also retain their wire grammar, which differs from controller opaque IDs. Source text, diagnostic prose, signatures, labels, versions, and rendering output remain text.

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: pulldown-cmark for Markdown, markdown-rs for MDX, and one extraction sweep over a tree both feed. They are 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-adoc and amiss-rst hold the AsciiDoc and reStructuredText extractors. Each depends on amiss-wire alone, and only amiss-scan consumes them.

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. It also carries the thirteen heading-identity rules, thirteen rows for eleven renderers since mdBook and MkDocs carry two spellings apiece, every one pinned against the renderer it models rather than written from its documentation.

amiss is the binary: the closed public command grammar, the in-process run, the five output formats, and a private sealed entry reserved for the bootstrap. amiss-bootstrap validates a pinned action tree and externally supplied constraint as data, validates three canonical requests, and launches the verified engine with a cleared environment and a closed stdin frame. It is the root production crate allowed to start a process, and the process it starts is the binary it just verified. The sealed path exists but is not integrated into the published convenience Action; Project status keeps that distinction explicit. A ninth crate, amiss-fixtures, exists only for tests: it writes hostile Git bytes straight into test repositories so the same fixtures exist on every platform.

The root api/ specialist and controller/ crates sit outside that graph. They are unpublished and nothing above depends on them. amiss-api normalizes bounded Rustdoc JSON into semantic records without entering provider binaries. The controller crates keep provider, HTTP, storage, credential, Git acquisition, and runtime dependencies out of the scanner. amiss-controller owns the provider-neutral orchestration and supervised bootstrap contracts; amiss-controller-git owns bounded protocol-v2 acquisition; and amiss-controller-service owns the bounded webhook, synchronous evaluation, and authenticated artifact endpoints, durable raw inbox, and worker. Small provider crates and service binaries add the GitHub App Check Run, GitLab merge-train policy job, and Gitea or Forgejo dedicated-reviewer gates. All durable state uses ordinary files rather than SQL or a database.

Controller delivery defines the neutral record and retry rules. Provider-verified controls compares the concrete flows and links each provider’s setup and trust boundary.

Inside an engine run, the stages form a line:

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

Each stage charges resource counters at a defined admission or observation point, and a crossed ceiling is a refusal, never a repair. Not every counter is a pre-work bound: document bytes are admitted before parsing, while parser node and nesting totals are charged after the grammar returns. Security model records the CPU-boundary limitation that follows from that ordering. Subject to those declared inputs and boundaries, the report is a pure function of the two snapshots and the invocation.

Last change: , commit: 0987dade

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. GitHub’s raw form and its content host are this repository, since that is how a README links its logo when a package registry renders the README somewhere else. 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. The Bitbucket Cloud source form gives the commitish exactly one segment, so a candidate branch containing / is not guessed into its filepath. Bitbucket Data Center keeps the path before its revision query, so only an exact full ref or object ID in at=, or its path-bound until history pair, selects a version; abbreviated IDs and extra parameters remain version-scoped. A literal installation context without a reserved projects or users segment may precede the route, but a raw route is foreign. The line-anchor grammars do not leak either: #L10-20 selects lines only under gitlab, #L10-L20 only under github and gitea, #file.rs-10 only under bitbucket-cloud when the basename agrees, and #10-20 only under bitbucket-data-center.

The parser pin records its known differences instead of hiding them. Measured against the CommonMark and GFM suites and against GitHub’s own rendering, four differences touch link extraction, all under the Markdown profile. A footnote call inside a link or image label ([link[^1]](#), ![image[^1]](#)) is a link and an image on GitHub, and pulldown-cmark builds neither once the footnote is defined, so a reference written that way goes unseen. A www. literal with an unbalanced closing parenthesis in the middle of its path stops at that parenthesis and publishes the shorter destination. An email address under a single-letter top-level domain is not recognized at all. And the GFM spec text says ftp:// should autolink where GitHub’s renderer does not; GitHub wins, as before. Three of the four are under-reporting, the safer direction to fail, and every one is written down in the corpus notes with its example number rather than waiting to be discovered. Two upstream MDX test documents make markdown-rs panic; the engine catches both as PARSER_PANIC, and both live in the corpus as regression tests.

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.

Last change: , commit: f2311935

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. The strongest lesson in that record: 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; the scanner engine never touches the network and only ever speaks about one repository’s own files in two exact snapshots: a base commit and either a candidate commit or the staged index. Provider controllers can acquire the exact repository state before invoking that engine, but they do not make live URLs an evaluation input. Amiss 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 answers a heading anchor against the slugging rules of the renderers it models, and reports one that no rule publishes as a missing target. What it cannot say is whether the section behind a resolving anchor still means what the prose claims, and a renderer outside What thirteen renderers call a heading is outside the answer. A recognized numeric line fragment is narrower still: Amiss can select and compare those exact bytes, but cannot tell whether they still express the idea the prose intended. A relative destination the tree does not hold is asked again under the spellings a documentation router serves, which reaches a file the tree already holds or nothing at all. A leading slash can additionally resolve from sealed candidate build evidence only when an exact route and optional anchor map to a scanned source; everything else stays outside the answer. It validates no code symbol, live URL, or other repository. Those checks belong to a layer holding the right information: the site generator owns its permalink scheme and the language server knows its symbols. Where a supported construct reaches one of these boundaries, Amiss records the unsupported or out-of-scope semantics instead of guessing, because a guessed pass looks exactly like a real one until it burns you.

Reading paths out of prose was measured rather than assumed, and it is not close. Across this book, ruff, and uv, the strongest available signal is a path-shaped token inside a code span, and 55 to 85 percent of those name nothing in the tree; requiring a slash lowers the rate rather than raising it. What the non-resolving pile holds is documentation’s own teaching examples. ruff’s twenty-two most frequent are main.py, a.py, b.py, mypackage/__init__.py and their kind, 564 mentions that were never references and can never be fixed. A tool that reported them would file more than a thousand rows against ruff to surface the ten real missing targets the explicit checker already finds, and it would be worst exactly where documentation is densest, because the pages that teach with examples are the pages full of filenames that do not exist. So the engine reads link syntax and stops.

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 predecessor investigation compared the design with several neighboring approaches and recorded where each one wins.

Swimm won wherever docs were authored inside its platform: its auto-sync could repair a renamed token without a human. It has since moved to code modernization, and its site no longer offers documentation sync, but the comparison still holds for any tool of that shape. Amiss never edits prose because a structural observation does not authorize a semantic rewrite. A platform like that sees documents authored for it; Amiss reads supported document classes already present in the repository.

Fiberplane’s drift is the closest mechanism, and for a small set of hand-placed anchors it can be the right amount of tool: an authored @path#Symbol precisely states what should be checked. It checks only what someone annotated. Amiss starts from zero authoring and discovers its closed document set automatically, reporting supported references and explicit coverage boundaries without claiming to understand every sentence or path-like phrase.

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.

Last change: , commit: 6d3f8a85

Amiss and link checkers

Link checkers and Amiss solve different halves of one problem, and the halves compose. A link checker asks whether destinations are alive, most valuably the external ones: it fetches URLs, follows redirects, and reports the dead. Amiss asks whether the repository agrees with its own prose: it resolves every in-repository reference against an exact Git snapshot, compares two snapshots to see what moved under what, and gates the change that broke the agreement.

lychee is the strongest of the checkers and the one worth comparing against honestly. It is fast, async, and reads Markdown, HTML, and reStructuredText; it checks external URLs, which Amiss never fetches by design; it checks local file links, and with --include-fragments it verifies heading anchors, which Amiss also does, against thirteen pinned renderer rules. If your failure mode is dead links on a published site, lychee alone is the right tool, and nothing here argues otherwise.

The composition is literal rather than aspirational. Every external destination is recorded in the report after the format’s own decoding, the address a fetcher would request, and the external plan turns a written report into the delta a checker actually wants: the destinations this change introduced, not the whole corpus every run.

amiss check --repo . --object-format sha1 --base "$BASE" --candidate "$(git rev-parse HEAD)" \
  --profile observe --format json > report.json
amiss external-plan --report report.json --format json |
  jq -r '.payload.introduced[].destination' | lychee -

Checking only what the change added is what keeps a checker tolerable in a gate: the author can fix a link they just wrote, and the pre-existing corpus rots on its own schedule instead of failing every pull request.

What a link checker cannot see is change. It examines one state of the world, so it can say a target is missing but not who removed it, whether it was missing before your pull request, or that a target still resolves while its content moved out from under the paragraph citing it. Those questions need two snapshots and exact comparison, and they are where Amiss lives:

Amisslychee
Checks external URLsnever fetches, lists them for youyes
Checks heading anchorsagainst thirteen pinned renderer ruleswith --include-fragments
Compares two snapshotsalwaysno
Attributes a finding to the changeintroduced, pre-existing, resolvedno
Reports changed content under unchanged proseyes, as advisoryno
Checks the staged index before commit--indexno
Policy can loosen the gatenever, loosening is itself a findingconfiguration is open
Byte-identical reports with digestsyesno

The smaller checkers sit on the same side of the line as lychee with less reach: markdown-link-check and linkinator examine files one state at a time from JavaScript, and mdbook-linkcheck is scoped to mdBook books and was last released in 2022. None of them compares snapshots. markdownlint’s MD052 reports a full or collapsed reference link whose label nothing defines, which renders as bracketed text rather than a link.

Documentation generators check their own links while they build: Docusaurus through onBrokenLinks and onBrokenMarkdownLinks, MkDocs through its validation settings, Sphinx under -n -W. Each sees only its own pages, reports built routes rather than source lines, and runs only when the site is built, so none of them says which change broke a link. They catch what this engine declines, slash-rooted routes and generated pages above all, which is why a site wants both.

For a repository with a published site the honest answer is both tools: lychee for the web, Amiss for the tree. For a repository whose documentation points mostly at itself, which is most repositories, Amiss covers the surface that actually breaks and notices the thing no checker looks for: the code moved and the prose did not. Every row of that report explains itself.

Last change: , commit: 6d3f8a85

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, and it is public: spec_to_rest, a Scala compiler with machine-checked proofs, three code-generation targets, a published docs site, and 22 CI workflows. It already ran roughly a dozen hand-built drift defenses: 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 removed working dossier remains available at the immutable pre-extraction commit: the repository audit contains the observed cases, and the experiment index points to the recorded measurements summarized here.

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.

Last change: , commit: b82f7b0e

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.

The deepest cut was simpler than any of those. 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, and experiment data remain in the repository’s history as an immutable dossier. The machine-readable contracts, schemas and canonical examples ship in spec/, led by the current report contract. 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.

Last change: , commit: b82f7b0e

Development

The toolchain version is pinned in rust-toolchain.toml, today at 1.97.0, unsafe is forbidden in every crate, and the lint table denies panics, lossy casts, wildcard matches, and undocumented errors. The version in that sentence is this book’s first live value claim: the definition below pins the pinning line itself, so a toolchain bump that forgets this page fails the repository’s own gate with the corrected expectation in the finding.

Gate-tool versions live in one place, the workspace.metadata.tools tables of the root manifest. Runtime consumers use Cargo’s projection directly: the CI tools composite and ratchet hooks query cargo metadata with jq. The pinned tool names and versions key a shared cache warmed by main; jobs install only their requested tools on a miss, and pull requests never save partial benches. The agent lanes use the same composite and gh-aw’s native Copilot installer without private CLI caches. A documentation-contract test parses the manifest independently and refuses any workflow spelling a declared tool at another version. Bumping a tool is one edit, except cargo-about: a release restores no cache, so the release job and the self-scan that mirrors it spell its version in the workflow, and that test holds both to the manifest.

Hooks run through prek: formatting and the cheap checks on commit, then Clippy with warnings denied, the full test suite, cargo deny, cargo shear, and a similarity-rs twin-function gate on push. The tool compares functions within one file, so a file that did not change keeps its edges, and the gate scans only the Rust files that differ from the base commit, in both trees. It also concatenates the deliberately parallel provider transports, lane-test harnesses, service runtimes, and verification files, and compares those when one of them changed, mapping the generated lines back to their source paths. Pair identities are stable and follow renames. Every candidate edge must already exist in the base set, so removals pass while new relationships, including an equal-count remove-and-replace, fail. Nothing is cached, and no baseline or allowlist lives in the tree. A last push-stage hook runs cargo-sweep over target/, dropping artifacts and incremental sessions older than two days; cargo never collects superseded builds, and this repository mints a fresh copy of every test binary on each lockfile or version change. Five days held 86 GB and the sweep reclaimed nothing from it, because every generation was inside the window. The hook is a no-op where cargo-sweep is not installed. Code CI runs the same two hook stages. What CI adds on top is the work that does not belong on a developer’s machine: the fuzz packages’ release runs, whose release builds cost minutes, and mutation, which costs ten of them for a code change. A push should not buy what a pull request already measures.

CI also runs the module-cycles lint through Dylint, pinned by commit under workspace.metadata.dylint. It reports sibling modules that depend on each other in a cycle, where a module counts together with everything nested in it and a parent and its own child are never compared. The workspace has none since #1133 was worked through, so the job denies the lint and a new cycle fails it.

The shared change detector compares Git revisions, including renames and deletions. Documentation-only changes retain the cheap hooks, documentation contracts, book build, and self-scan, but skip unrelated platform, coverage, fuzz, packaging, and mutation builds. Reviewer-only changes retain the cheap hooks and Actions analysis. Rust, fixtures, schemas, build configuration, and unknown paths take the full code lane. The required workflow always starts, and gates fails if classification fails; there are no workflow-level path filters. Weekly and manual CI runs force full code validation. The platform and coverage suites include the JSON, generated-report, and documentation contracts. The separate contract job runs documentation contracts on documentation-only changes.

The commit hooks also reject handwritten Serde implementations, callbacks, trait-bound overrides, and custom JSON representation guards. Models use derives and library adapters; domain checks remain separate. See Architecture for the standard Serde input behavior and the canonical representation required by authenticated boundaries.

Tests use the production wire models too. Load published examples into those types, mutate typed fields for domain cases, and call the production validators. Raw JSON mutations and independent byte fixtures cover representation and malformed-input cases; they do not need parallel wire structs or test-only encode/decode helpers.

Coverage executes the whole workspace with instrumentation and keeps the 85% line floor, but excludes the three fixture-only packages from the report. It does not replace uninstrumented platform tests or the release-mode eligibility checks. Rust caches remain separate across platforms and build settings; disposable CI runners remove their unused floating stable toolchain before restoring them so runner image updates cannot invalidate the pinned-toolchain key.

The test profile strips debug information from executable fixtures, which are repeatedly copied and verified. Symbols and assertions remain; source-line backtraces can be restored locally with CARGO_PROFILE_TEST_STRIP=none. Every dependency is optimized in development and test builds, since dependencies are cached and most tests spend their time in them. The workspace crates are not: a package override reaches the package’s test binaries too, and those are what a build waits on. Debug assertions and overflow checks stay on, and release settings are unchanged.

Compiling the tests costs far more than running them, so platform CI builds the binaries in one step and runs them in another: a slow run says which half it was. The build is throughput bound on a four-core runner, so each platform splits in two. The libraries half builds the engine’s library crates; the programs half builds the CLI, the bootstrap wrapper, and every package under controller/ and api/, whose tests start a process per case. A new crate lands in the libraries half unless it lives under those two directories. Each half keeps its own dependency cache, since Cargo resolves features for the packages it builds and the halves resolve some shared dependencies differently. A restored dependency cache still leaves every workspace crate to compile. To see where that time goes, add --timings to cargo nextest run --workspace --locked locally and read Cargo’s report next to nextest’s summary.

Windows links the test binaries with rust-lld and runs eight test threads, since starting a test process there costs more than running most tests.

Platform CI also builds without debug information. The test profile strips it at link time, so generating it only hands the linker bytes it discards. Local builds keep their line tables, since the setting is an environment variable on the CI job rather than a profile change. CARGO_PROFILE_TEST_DEBUG and CARGO_PROFILE_DEV_DEBUG turn it back on anywhere.

The wire integration tests share one executable, so the Serde code they instantiate compiles once instead of four times. That crate sets autotests = false and names its own target, so a new file under crates/amiss-wire/tests runs nothing until a module declares it; add cases to the modules that are already there. Targets with no unit tests set test = false, which skips an empty executable per target; turn it back on when you add one, since integration tests reach those libraries and binaries either way. Two targets stay on their own. The fatal-envelope test counts allocations process wide, and the two release eligibility checks live in eligibility so the extra release build stays small. They run in their own job, on Linux for a pull request and on every release platform for main and the weekly run.

Two similarly named files point in opposite directions. .pre-commit-config.yaml is the hook table this repository runs on itself through prek. .pre-commit-hooks.yaml is the hook this repository publishes: a consumer’s own pre-commit configuration names this repository and reads that manifest to discover the amiss staged-index check shown in Running it in CI.

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

cargo test -p amiss-fuzz -p amiss-controller-fuzz --locked --release

The first pair checks every crate, engine and provider alike, from one lockfile. The third replays the committed fuzz seeds in release; the coverage-guided runs need nightly and cargo-fuzz on top, from the same lockfile. The trust boundary is a dependency boundary rather than a workspace boundary: the compiler-output specialist under api/ and the HTTP, provider API, Git acquisition, credential, storage, and service-runtime crates under controller/ are unpublished. deny-engine.toml drops them from the graph and then bans the network and async stack, so what an amiss user downloads cannot acquire it. The prek hooks run the first pair and Linux CI runs both. The macOS and Windows jobs also run the controller tests, including the cross-process file stores, provider authentication, worker, and supervised-process cases. The supported service deployments are documented in Provider-verified controls.

Tests answer to a house rule called the teeth check: important tests are exercised against deliberately broken behavior before they are trusted. The mutation lanes publish a non-gating measurement of that property, in three sizes: per pull request, per push to main, and on demand, each on its own trigger.

Every pull request measures only the mutants the change itself reaches, over shards counted from the mutants the diff actually reaches rather than from a guess, because listing them needs no build. Zero mutants skip the worker entirely; nonempty diffs use ten mutants per shard, capped at eight shards. That lane asks the whole workspace whether each mutant lives, at about twenty seconds per mutant. The shards used to pay a worse floor, a cold workspace build plus a baseline test pass repeated in every shard; now each shard restores the build cache that the baseline job saves on code pushes to main, and skips its own baseline because required CI proves the same commit, so what remains is the build delta against the last merge and the mutants themselves. A release pull request measures its version and generated-file diff like every other pull request. The code it packages was already measured before each change reached main; measuring the accumulated release again would duplicate that work and eventually exceed the bounded PR lane.

The sweep over every mutant in both workspaces runs only when someone asks for it, through workflow_dispatch. It is split across shards sized from the mutant count rather than a fixed number, it takes tens of minutes, and it exists to find gaps in code that no longer changes, which is not something a release should pay for. Fixture crates are excluded, because code that exists to be exercised by its callers says nothing about the tests. Unlike the smaller lanes it runs each mutant against its own package’s tests, which is what makes it affordable and also means a mutant that a sibling package covers is reported as surviving. Its output is a list to verify, not a verdict.

The sweep of 2026-07-28 is the reading to compare against: 6,523 mutants, 4,103 caught, 1,335 missed, 1,085 unviable, no timeouts. Roughly half of those missed are the scoping artifact above, measured at 43% on one file and 62% on another, so the number of real gaps is nearer seven hundred. A later sweep that misses far more has either lost tests or gained untested code, and the point of writing the figure down is to be able to tell which.

None of the three gates a merge and none certifies a global mutation threshold: a surviving mutant is a place where a lie would go unnoticed, to be judged against whether the perturbed value is observable through real behavior, not a score to raise.

Three agent lanes sit beside the gates, none of them gating, written as gh-aw workflows whose agents hold no write token at all: they run read-only on DeepSeek inside the repository’s runners, and a separate harness job posts what their structured outputs request, nothing else. A new issue gets its premise checked against the tree before a maintainer reads it, whoever opened it. Every push to a same-repository, non-draft pull request gets one consolidated review, summary and line-anchored comments in a single card, and a review can be dispatched by hand for any PR number. And /oc in a comment summons the agent, which can answer, fix, and propose a pull request through the same structured channel. What the lanes read is a claim under test, never instructions; only the mention lane takes tasks, and only from a collaborator’s comment. Each comment carries its run link, and the full agent log survives as a run artifact. 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; the corpus notes document every known difference. Scanner parsers that take untrusted bytes have targets under fuzz/. The controller fuzz package signs generated provider requests before varying their facts, so its account-free targets reach the provider identity and binding checks. Both suites carry committed seeds 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.

Every pull request also packages what a release would upload. cargo package over the publishable members resolves their siblings through a temporary local registry and builds every tarball, so a file dropped from a package, a path dependency missing a version, or a new crate its dependants cannot see fails on the pull request instead of halfway through an upload that cannot be taken back. It is cargo package rather than cargo publish --dry-run because the dry run prefers a version already on crates.io over the tree, which makes it blind to exactly the crate a change is adding.

Releases are automated. A bot keeps a release pull request current with the version bump, changelog, and exact Action-dispatch ref. Merging it publishes the crates and source tag while the GitHub release remains a draft. The release workflow then assembles the immutable action/vX.Y.Z tree and exercises both that exact tree and the source-tag dispatcher on Linux, both macOS architectures, and Windows. Only a green smoke matrix advances the stable major ref without rewriting history and makes the release public; prereleases never advance the major ref. The same gate governs the release assets: the per-platform engine and prober binaries, their THIRD_PARTY_LICENSES.txt, their SHA256SUMS, and the sigstore bundle attesting that file attach to the draft, so a release that fails the matrix never publishes a binary. If a forge outage leaves that pull request stale, manually dispatching the release automation on main refreshes its metadata without running the publishing job; crate publication remains restricted to pushes on main. Security checks layer in CI as well. Dependency update PRs arrive with a cooldown, a weekly advisory re-check runs against a fresh database, and CodeQL covers both the Rust and the workflows. The Rust analysis takes about ten minutes, so it runs on main and weekly, and a pull request gets the workflow analysis. Scorecard, secret scanning with push protection, and build provenance attestations on release binaries round it out.

Last change: , commit: e840e6b9

Project status

This page describes the supported surface on main, not the history of individual releases. Versions and release-specific changes live in the changelog. Future work and its entry conditions live in the Roadmap.

Supported surface

AreaCurrent contractImplementation anchor
Commandamiss check compares a base commit with either a candidate commit or the staged index, amiss fix applies the staged evaluation’s proof-gated fixes to the working tree, amiss claim authors a value claim proven against the working tree before it is printed, amiss policy-include authors one validated exact-suffix policy row and can preview its staged matches, amiss record-set converts normalized specialist rows into a checked self-asserted semantic template without opening a repository, amiss adopt mints an adoption-debt snapshot from a commit pair’s eligible findings, amiss external-plan derives the delegated-evidence destination delta from a written report without opening a repository, amiss external-assess judges that plan against a producer’s observations under a fixed offline policy, amiss locale-assess judges a locale coverage plan against a producer’s page inventories under the same offline discipline, amiss locale-inventory reads that page inventory pair out of the checkout the plan binds, amiss locale-plan writes that plan from an accepted report and a locale layout so no digest is sealed by hand, amiss render projects one validated report without evaluating it again, amiss refs returns the exact candidate occurrences that refer to one repository path, amiss --help prints that closed grammar, and amiss --version reports the binary’s version, with its engine digest when the binary can read itself. The command grammar is closed at those fifteen forms.CLI parser
Repository accessThe engine reads Git objects, packs, deltas, trees, and the index directly. It does not invoke git, follow repository symlinks, or fetch missing data.Git store
DocumentsBuilt-in discovery covers Markdown, GFM, MDX, AsciiDoc, reStructuredText, six extensionless Markdown basenames, and two plain-advisory basenames. A Sphinx conf.py naming the suffix it reads widens that set under its own directory, so a file carrying a declared suffix there is read as reStructuredText while the same suffix elsewhere in the tree is not a document. .ipynb and .org are discovered and counted as unsupported-document-format, with no parser behind them. Repository policy may add exact paths or trees narrowed by one exact suffix, each optionally bound to one built-in grammar, without installing another parser.Classifier
ReferencesRelative repository paths and same-repository GitHub, GitLab, Gitea-family, Bitbucket Cloud, and Bitbucket Data Center URLs are resolved under their declared dialect. Full immutable IDs are accepted only in the run’s object format and resolve against that exact commit when every required object is already available under the declared Git roots and budgets. A complete local walk may prove a missing historical path; unavailable objects retain the exact ID and path as unsupported historical scope and enter the provider-evidence plan, while the engine never fetches them. A relative destination the tree does not hold is asked again under the spellings a pinned documentation router serves, which can only reach a file the tree already holds. A generator whose configuration file is in the tree anchors Antora resource IDs, Docusaurus bare paths and @site/ aliases, and absolute Sphinx :doc: targets at the directories it serves them from, again only to files the tree holds. A repository whose generator is configured somewhere else names that router in .amiss/router.yml, which selects only rules whose every spelling reaches a file the tree holds, and the URL path that directory is served at, which reads a leading-slash route as a path under it when the tree holds the page it names. A missing candidate path carries evidence of a unique unchanged relocation when the base and candidate entries have the identical Git mode and object ID, and a plain relative path that reached it takes that move as its repair. Numeric line fragments select and compare an exact inclusive byte range, and a heading anchor resolves when any of thirteen pinned renderer rules would publish it or the document declares the identity itself, in raw HTML, in an attr_list block, or in the MDX comment Docusaurus uses. Option-free local AsciiDoc and reStructuredText includes expand recursively from the frozen scanned set, and literal includes never parse their target as markup; dynamic, selected, nested-context, unavailable, and AsciiDoc build-state-dependent absence retain conservative boundaries. Unsupported shapes remain visible in the report, and a delegated destination is recorded where it is seen, never fetched.Resolver
Policy.amiss/scanner-policy.json may expand discovery, own exact visible source, tree-inventory, and inventory-count projections, and raise the disposition of missing targets, target-type mismatches, and invalid references. It cannot downgrade or suppress a finding.Policy application
ReportsMachine output uses the frozen report envelope and payload contract. Exact findings remain the evidence surface; engine-grouped Fix, Check, and pre-existing feedback is its review projection. The wire is versioned by the in-report compatibility field, not by the engine release; it reads 3 since the tolerant-reason reshape, additive within the major, with the frozen example that opened the major holding every later schema to that promise. Inside the major a document’s unsupported reason and an unsupported-semantics reason are open strings a reader keeps and rewrites byte for byte; every kind, status, disposition and schema name stays closed and is refused when unfamiliar.Current schema
Semantic evidenceA separate 16 MiB envelope binds one external producer and at most 100,000 canonical observations to the exact candidate and optional source report. Every sealed value also carries the controller-plan-owned context digest its producer must reproduce. Envelope and template readers reject unknown observation kinds and fields. Compiled consumers resolve unique prefixless Sphinx labels; exact built-site routes, anchors, redirects, and navigation; and exact values, sorted rows, or counts from complete record sets. The public check command can bind one candidate-free local template after resolving the exact commit or staged-index identity, but that caller-selected input remains self-asserted. The offline record-set form can assemble such a template from strictly validated normalized rows while leaving specialist digests, completeness, and authority as caller assertions. An isolated operator binary normalizes bounded, format-matched Rustdoc JSON into a complete record set of root-crate public free, inherent, and trait function declarations without invoking Cargo or entering provider binaries. Provider plans produce the bounded operator-local or cached Sphinx inventory set. Trusted acquisition adds strictly checked candidate-independent template bytes under a unique planned acquisition identity; the controller binds the exact candidate and constructs a bounded audit value from the source and envelope bytes. The controller can derive routes, anchors, and navigation from a pinned mdBook renderer context and caller-opened completed HTML output, bound to an exact configuration path, publication prefix, locale, and version. The GitHub provider lane can acquire a plan-frozen workflow artifact for the exact candidate; the other provider lanes expose no workflow-artifact source.Contract, Rust producer, inventory producer, mdBook producer, and consumer
Publication auditsClosed digest-bound contracts model an operator-owned publication plan, one provider-normalized successful-deployment receipt, and a conservative offline matched/refuted/unproven assessment. They bind the accepted report and exact docs, target, completed-site, product, deployment, workflow, producer, and evaluator identities. The controller validates that a complete chain describes its exact scanner report and replays to the supplied assessment. Its artifact store retains and reopens the exact report, plan, optional evidence, assessment, digest set, and verdict as one immutable evaluation-bound record. No command or controller lane acquires, stages, or publishes it yet.Publication audits
Locale coverage auditsOne closed digest-bound plan binds an accepted report and exact docs candidate to a site, locale pair, independently selected producer, operator-owned coverage/fallback/optional target-lineage policy, and an optional immutable product resource reused from publication audits. Evidence carries independently complete source and target inventories with independent nullable product receipts; every target page is target-owned with nullable exact source lineage or declares an exact fallback class and source digest. The offline assessment reports proved missing/orphan pages, fallback status, current/stale/unproven target lineage, and matched/refuted/unproven product identity for each side. Only an exhaustive clean selected comparison matches. Exact lineage and product equality prove only the named digest relations, never translation quality or semantic equivalence. amiss locale-assess judges a plan and evidence offline, and the controller validates a complete chain before retention: the plan’s report digest and docs candidate must describe the supplied accepted report, and the assessment must replay exactly from the supplied plan and optional evidence. Its artifact store retains the exact report, plan, optional evidence, assessment, digest set, and verdict as one immutable evaluation-bound record. amiss locale-plan writes the plan from an accepted commit report and the locale layout, and amiss locale-inventory stages the page inventory pair from the candidate tree itself for the directory-per-locale and suffix-per-file layouts, keyed by the path under each locale’s root, reporting a target page that still carries the source bytes as a source-identical fallback for the plan to authorize or refuse; gettext catalogs have no reader yet and no controller lane acquires an inventory.Locale coverage audits
Cross-repository relationsThe provider-neutral controller atomically freezes operator-owned two-subject relations, one opaque pair/release/workflow coordination identity, and all four exact base/candidate commit/tree identities without inferring intent from timestamps. A pure admission law assigns the first exact transition a fence, preserves identical work as a duplicate across either trigger role, rejects stable-identity rebinding, and advances the fence when a different coordination supersedes pending work. A bounded file-backed store applies the same law under a cross-process lock and atomically committed hash-chained journal; it serializes concurrent admission, recovers an uncommitted append after restart, and refuses exhausted capacity or missing, shortened, mutated, or rebound committed state. Exact historical retries retain their first fence without rolling current work back. The existing strict Git transport acquires each subject into a physically independent root under per-subject and aggregate streaming object/byte limits, rechecks every commit-tree binding, and returns the complete relation as unproven on any unavailable subject. A closed digest-bound plan retains the accepted report digest, trigger role, relation context, coordination identity, shared projection, human-readable repositories and selectors, and all four exact snapshots without copying credentials into the wire. The provider-neutral Git layer binds that plan back to the frozen transition and projects exact blob-line, named-region, or tree-path sources from all four acquired snapshots under joint record/byte budgets; unavailable sources produce null slots, while record values and sets await a trusted snapshot-bound producer. A separate plan-bound receipt gives each role independent nullable base/candidate slots; a present slot is only a projected-value digest and exact byte length, while null cannot claim emptiness or inequality. The replayable offline assessment binds the report, plan, optional evidence, and evaluator, then symmetrically classifies the complete equality transition as aligned, introduced drift, pre-existing drift, or resolved drift; absent, foreign, misrouted, or partial evidence remains unproven. Before immutable restart-safe retention, the controller reopens the accepted report, binds its repository, target, coordination, and snapshots to the trigger role and frozen operator transition, and independently replays the plan, optional evidence, and assessment. Pure status preparation requires both complete registered subjects at their still-current candidate commits and freezes only configured destinations under the pending fence. A second pure transition replays the retained audit against that pending work, freezes the exact target/audit record, resumes an unfinished exact retry, rejects immutable-field substitution, and completes the exact record idempotently. The same bounded journal commits status stage, per-destination acknowledgement, and completion actions under the scheduling lock, verifies the retained artifact before first stage and unfinished replay, and retains compact digest bindings instead of duplicating provider configuration or credentials. A provider-neutral delivery claim selects the oldest unresolved fence for each stable destination, reacquires its exact registry and artifact record, and holds one of 256 deterministic OS-lock shards across provider I/O. Dropped claims recover without durable mutation; a newer coordination cannot pass an unresolved older destination; unrelated shards remain parallel; and the final acknowledgement completes the batch, including recovery from failure between the two journal actions. The outbox itself performs no provider call. The GitHub installation and Gitea-family clients resolve exact registered branch heads. GitHub reconciles App-owned relation checks, while Gitea-family reconciles dedicated-reviewer commit statuses with its documented writer-binding limitation. A GitLab adapter resolves only the ephemeral candidate of an authenticated active policy job and returns the exact staged relation decision after a final live refresh, without making a provider write. The shared service loads one bounded strict-JSON operator registry and derives repository hosts from provider instances. An immutable generic router binds every required credential identity to one caller-owned authority under its exact provider and integration and rejects missing, unused, repeated, or rebound rows. Coordination admission consumes one authenticated delivery and accepts only an opaque operator identity for a relation owned by its exact trigger set. Provider binaries do not construct or install those values yet; snapshot acquisition and live lifecycle integration are not built.Cross-repository relations
GitHub convenience ActionA source-tag dispatcher selects the same version’s immutable runtime tree. The runtime derives snapshots from supported GitHub events, verifies the selected engine against its manifest, shows at most ten grouped items, and annotates only displayed Fixes. It is not a provider-authenticated controller adapter or an independent trust boundary.Dispatcher and runtime
GitHub provider laneA source-built App service authenticates pull-request events, refreshes exact state and strict App-bound rules, acquires exact SHA-1 objects and optional plan-frozen workflow artifacts, runs the sealed bootstrap, and publishes on GitHub’s test-merge commit. GitHub.com and compatible GHES releases are supported.GitHub setup
GitLab provider laneA source-built service authenticates a pipeline execution policy job through OIDC, verifies its enforced merge train, policy origin, runner, project, and exact train commit, then lets only an exact pass make that job succeed. GitLab 19.3 or newer with Ultimate is supported.GitLab setup
Gitea and Forgejo provider laneA source-built service authenticates pull-request webhooks, refreshes the effective protected-branch rule, acquires exact SHA-1 objects, runs the sealed bootstrap, and approves or rejects through one dedicated reviewer. Gitea 1.27 or newer and Forgejo 16 or newer are supported.Gitea and Forgejo setup
Provider service operationEvery service has an offline configuration check, separate liveness and readiness, fourteen fixed label-free counters, redacted lifecycle events, and graceful drain. The listener and its operator endpoints remain private.Service operation

Repository form is deliberately closed too. The reader accepts the non-bare checkouts: a primary checkout whose .git entry is a real directory, a linked worktree, and a separate-git-dir checkout. The .git file forms resolve through one bounded gitdir: indirection plus at most one bounded commondir hop, symlinked targets are refused, the index always reads from the worktree’s private directory, and depth is structural rather than configured. Bare repositories stay unavailable directly, though a bare main’s linked worktrees read through their commondir, and alternate object stores are not consulted. The repository boundary and its boundary tests pin that behavior.

The supported reference surface is intentionally smaller than “every path-like phrase in prose.” Bare filenames in ordinary text are not inferred; raw HTML and MDX code regions are opaque; leading-slash site routes without sealed build evidence or a declared publication base, code symbols, live URLs, and references to other repositories are not validated under those systems’ semantics. Their visible boundary behavior is described in Discovery and Resolution.

Trust surfaces

The repository contains strict parsers and canonical writers for evaluation, snapshot, and external-control requests, plus evaluation logic for organization floors, adoption debt, waivers, trusted time, and execution constraints. The evaluation identity separates the candidate ref used for same-repository URL resolution from the protected target ref used by the branch-scoped floor, trusted-time, debt, and waiver gates. The public command still supplies all five external controls as absent and has no target-ref option; its repository and candidate-ref fields remain caller assertions. check may add one caller-selected semantic template, which does not change that trust level. The forge field selects a URL dialect, not an authenticated provider. Compare the request schemas, strict parsers, pipeline shell, and CLI wiring.

amiss-bootstrap now has a sealed engine path. It bounded-captures the three request files, requires their canonical forms, a complete repository/dialect/ref identity, and coherent commit-pair materialization, matches the embedded execution constraint and trusted-time provider/run tuple, checks that both requested commits were pre-acquired, validates the action tree and runtime closure, and then sends only a closed evaluation/snapshot/controls frame over stdin to the verified engine. The child receives the repository as its fixed working directory, a cleared environment, one private engine argument, and no caller-selected engine command. Report acceptance rejects an unavailable hybrid and binds the requested profile, both commits, candidate and target refs, candidate identity, provider run and trusted instant, the exact presence, digest, and trust source of the organization floor, debt snapshot, and waiver bundle, and the execution constraint’s digest, trust source, and recomputed semantics, plus every supplied semantic envelope’s payload and producer/input identity after its plan-owned context matches. It likewise recomputes the trusted-time statement’s semantic digest and requires the sandbox provenance to remain self-asserted. The wire crate exposes checked constructors and canonical writers for the execution constraint and trusted-time statement, and the controller uses them when it derives a sealed job. The source-built amiss-constraint companion reads an exact local action commit and bootstrap, derives the redundant execution fields, validates the dependency locks and selected runtime closure, and writes the canonical constraint without fetching or authenticating either input. The release workflow builds the sealed action and bootstrap path; it does not publish the companion or provider services. The published composite Action still launches amiss directly, while separately operated provider services acquire their inputs and invoke the sealed path. The distinction is visible in the bootstrap entry point, constraint producer, release assembly, and Action execution.

The unpublished crates under controller/ define the provider-neutral identities, bounded ingress, rotating verifier keys, durable raw inbox, delivery record, retained artifact store, worker, orchestration, acquisition, and supervised runner contracts. Its state uses checksummed ordinary files with fixed capacity and atomic replacement, not SQL or a database. Controller delivery records the cross-process ownership, heartbeat, replay, and exact-publication retry rules; authenticated report and assessment retrieval is defined by Retained provider artifacts.

The provider crates add signed-input decoders, controller-owned credentials and API clients, strict merge-rule authorization, fixed-budget protocol-v2 Git acquisition, and provider evidence. GitHub uses an App-bound required Check Run on the test merge. GitLab uses an independently owned pipeline execution policy job on an enforced merge train and authenticates that job through OIDC. Gitea and Forgejo use a protected approval restricted to one dedicated reviewer. The workspace keeps those adapters separate instead of turning provider differences into one closed provider enum. Provider-verified controls compares the supported lanes and links their exact setup, retry limits, and trust boundaries.

All three service binaries share an offline configuration check. It validates local configuration and named trust inputs before creating the runtime or touching provider and mutable service state. Once running, their shared service operation contract separates liveness from readiness, keeps metrics and lifecycle events bounded, and drains admitted work without losing a durable webhook backlog. These are deployment tools, not retained live-provider evidence.

Local and convenience-Action reports still describe repository policy with no outside authority consulted. Their external controls are absent, an optional local semantic template has no outside authority, and sandbox assurance is self-asserted. A report produced through a provider lane can contain verified external controls, but the report does not authenticate who supplied them and gains no provider_verified field or signature. Provider evidence lives in the App-owned Check Run, protected policy job, or dedicated review and the matching merge rule. See Controls and policy and The report for the exact distinction.

Keeping this page honest

Links from factual prose to the implementation are deliberate. The repository’s own Amiss scan makes a changed dependency under unchanged prose visible for review.

The mechanical claims are generated, not maintained. Default dispositions in Profiles and findings and resource ceilings in Limits and refusals come from the Rust constants through a test, so changing a constant without the book fails CI. The same documentation contract test finds every public schema-backed example, validates it against its schema, and feeds it to its owning typed reader; a contract without a registered reader fails CI too.

The examples execute. The report’s readable form passes the Serde JSON reader, and its canonical bytes clear the wrapper acceptance law end to end. The commit and staged-index identity preimages reproduce the production digest chain in the identity golden test. The published semantic corpora drive their live code paths: frontmatter vectors through the recognizer, correlation vectors through the intent projection, and governed-definition vectors through report construction, value-grammar refusals included.

Published CI snippets must pin upstream Actions immutably, name an explicit reviewed crate version, and advertise the current release major. Version strings inside example fixtures are reproducible evidence, not claims about the latest release. None of this proves the meaning of free prose. It makes the mechanical drift visible, which is the part a machine can own.

Last change: , commit: 8d2743aa

The scan ledger

The completed validation phase used counted scans of other people’s repositories, and this page retains the counts. One row is one scan: a public repository, a base and candidate commit pair, the observe profile, a release build. Raw values come from the run’s machine report or git diff over the same commit pair; historical density is derived and rejection class is assigned from those recorded artifacts, never remembered.

These scans predate the grouped PR-feedback contract, so their row-level numbers remain historical evidence rather than a product threshold. Advisory rows are findings whose effective disposition was warn; records are excluded. Changed documentation lines are the added plus removed lines git diff --numstat reports for Markdown files between the row’s two commits. The final numeric column is the old advisory-row density per hundred changed lines. It is retained to reproduce the study, not interpreted as reviewer effort or used as a gate; small denominators make it especially noisy.

July 2026

Ten repositories, scanned 2026-07-18 with the v0.5.1 release build under --profile observe, each from its latest release tag to that day’s default-branch head. That build resolved no heading anchors, so every reference counted here is a path or a line range. The same ten trees later supplied the anchor measurement behind What thirteen renderers call a heading, which is a separate study and not a row on this page. Two bases bend that convention: ripgrep tags rarely, so its base is the 150th ancestor of its head, and alacritty tags on release branches, so its base is the latest stable tag’s merge point with master.

RepositoryRangeReferencesMissingAdvisoryDoc linesHistorical densityRejection class
helix5cda70e86637..f6f3eb1fe4a73,2491472,1662.2none
ripgrepa6e0be3c909c..227381db0ee8766062142.8none
just2fd820433b02..e19eb9c379bc3,10101911.1none
mdBook2ea30c00f006..69287f26827e1,20636350undefinedtest fixtures
starshipfca92d8dcbd5..3c3aaf4f7ed27,50824284484,4851.0clean URLs
ruff0177a7e0d2c4..5055442b58755,2441021021,1468.9generated targets
bat979ba22628bc..78951393e29b451122721412.6none
fd7027d45303b4..1bfeea237a489601791.3none
hyperfine975fe108c4ee..f12f3d9f86f34801372.7none
alacrittya0be6eb8240c..852e971cddfa8715657.7none

helix’s one missing reference was a real introduced break: a guide page linked ./themes.md where the page lives one directory up, invisible to mdBook’s own build. A community pull request (helix-editor/helix#16034) was already in flight with the identical one-character fix, which is independent confirmation of the finding rather than a missed contribution. ripgrep and just were spotless on missing references; just’s single advisory row sits on a nine-line change, the small-denominator case that shows why the historical ratio is not a product rule.

The three rows with a named rejection class map the adoption boundary, and none of their missing counts is a resolver bug. mdBook’s 36 all live inside its own link-handling test suite, deliberately broken fixtures under tests/testsuite; its range changed no Markdown at all. starship’s 242 are extensionless clean URLs its site router resolves and the tree does not, concentrated in translation mirrors of the preset pages. ruff’s 102 name targets its docs build generates and the repository never holds, settings.md and rules.md mostly, plus three literal template placeholders. Amiss reads every one of these correctly against the tree; the maintainers would still close the report, and they would be right to, which is what makes the class worth recording. These are the measured adoption boundary that put declared generated targets on the roadmap, and the answer has since shipped from the tracked ignore file, recorded in Reference coverage.

The four later rows were picked deliberately from repositories without a docs-site generator, and they produced no rejection class at all: every nonzero count there is a real break. bat’s twelve are pre-existing and live in four translated READMEs whose relative links carry the wrong prefix, doc/LICENSE-MIT for a root file and a doubled doc/doc/ for siblings, and each renders as a 404 on GitHub today. alacritty’s one is pre-existing in the recorded range: an earlier commit moved the escape-sequence docs into the manpage and docs/features.md still links the deleted escape_support.md. fd and hyperfine were spotless. On this evidence the rejection classes are a docs-site phenomenon; a plain tree yields either zero or the genuinely broken.

The same ten trees, rescanned

Heading anchors resolve since What thirteen renderers call a heading, so the ten trees were scanned again on 2026-07-26 with that work’s release build. These are not rows. Each is a whole-tree count at that day’s head against a synthetic empty base, so there is no commit range, no changed-line denominator, and no density figure. They are kept because the class mix that Reference coverage later answered moved.

RepositoryHeadReferencesMissingAnchorOther spellingAbsent
helix079a789e8cb03,24910901
ripgrepf9c05a949d1a7660000
justaf06bc49df4d3,2210000
mdBook4f8c9460977e1,206371630
starshipeebb9a3c7ddc7,5093441032410
ruffa5cdc6d5813b5,28910420102
bat78951393e29b451197012
fdca51233d277e961100
hyperfinef12f3d9f86f3480000
alacritty852e971cddfa871001

Missing splits three ways, and the July study’s two rejection classes are two of them.

247 name a target the tree holds under another spelling: X where X.md is present, or an .html output name where the .md source is. All 241 of starship’s are that, eleven preset page names across twenty-two translations, and its presets/README.md links the same file twice in one paragraph, once as ./plain-text.md and once as ./plain-text. mdBook’s six are its own output extension, inside its guide and its fixtures. These resolve now, against the spellings harvested from the routers themselves. Two columns of this table read differently on the current build for that reason and one more: starship reads 103 missing, and mdBook reads none at all, its whole count having been fixtures under tests/, which discovery now skips by name.

146 name a target no spelling reaches, and 102 of those are ruff’s generated pages, 63 of them into docs/settings.md. That is the class a docs build writes and a tree never holds.

123 are heading anchors no rule publishes, which the July build could not see because it resolved none. 122 are real breaks in five repositories: 103 in starship’s translated pages, where the heading was translated and the English fragment stayed, every one of them checked against the rendered page on starship.rs and absent there; 9 in helix from one reference definition whose section moved; 7 in bat from case and translation; 2 in ruff and 1 in fd from changelog entries that moved out of the file. The remaining one is deliberate, inside mdBook’s link-handling fixtures.

Those 516 have since become 238, and the arithmetic closes: 247 resolve as router spellings, 30 left with the fixture trees discovery now skips, and one was the defect below. What remains is 122 heading anchors and 116 targets no spelling reaches, with mdBook joining the three that were already clean.

The rescan also found one false missing, which is a defect and not a class: just’s README titles itself with <h1 align=center><code>just</code></h1>, github.com anchors that, and the rule table did not. It got a pinned harvest and a fix, which is why just reads zero above.

The Sphinx yield, measured

The v0.14 release taught the reStructuredText adapter the two Sphinx roles and the label table behind :ref:. These are that work’s counts, taken 2026-07-31 on two Sphinx-native trees with the main build carrying the two lexer fixes below, engine sha256:5d1df7f8a4756f23aa7a78330ebc030438dc246f89cb06a7bb676062abc81c92. Like the rescan above these are whole-tree counts at that day’s head, base one commit back, so there is no range and no density figure. Django, the tree that motivated the adapter, was still unmeasurable when these counts were taken: its reStructuredText lives in .txt, which the built-in rows refuse. A policy include can now bind the rst adapter to exactly those paths, stated in discovery, and the measured row follows below.

RepositoryHeadDocumentsReferencesLabelsResolvedDuplicateInventoryMissing
pytestf306da747e703051,408460449065
cpythonac8ba0ca5a041,3074,7043,3663,365100

CPython’s Doc tree reads clean: every one of its 3,366 :ref: uses resolves through the label table except a single genuine duplicate declaration. pytest’s five label misses split two ways: package_env, twice in its changelog, is a tox setting no pytest label can satisfy and renders unresolved in its own Sphinx build, a real break found live; the other three are prefixless references an intersphinx inventory satisfies at build time, which no tree-only scanner can tell from drift, the boundary resolution states. The six inventory rows are the colon-prefixed form, declared unsupported rather than missing. The remaining missing counts in each tree’s summary, twenty and forty-two, are ordinary path references outside this study’s question.

The measurement earned its keep the way the just defect above did, twice over. The v0.14 build’s first pass read 131 of pytest’s labels as missing, and triage proved the docs innocent both times: pytest declares 180 labels in the backtick-quoted phrase form the lexer kept quotes on, and CPython’s three “dead” labels were declarations sitting in grid-table cells and a list item. Both false-missing classes got pinned fixes (#203, #205), which is why the table reads as it does.

The Django yield, measured

The binding shipped and Django stopped being the counterexample. These counts were taken 2026-08-10 at that day’s default-branch head, on the main build carrying the three lexer fixes below, engine sha256:3a7263e876ec5ccd55f3b4899f8189af4567b8b21051bec255d93ffba0257a34. The method bends one convention and states it: Django’s tree carries no Amiss policy, so the candidate is a local commit whose only change is .amiss/scanner-policy.json, then holding 674 document includes that bind the rst adapter to every .txt under docs/, and the base is the unmodified upstream head. The whole tree read in 1.1 seconds on an ordinary development machine. The exact commit tree was checked again on 2026-08-22: it still holds 674 regular .txt blobs and 66 differently suffixed blobs under docs. The current policy grammar names the same measured set with one {"path": "docs", "kind": "tree", "suffix": ".txt", "adapter": "rst"} selector without admitting those 66 other files.

RepositoryHeadDocumentsReferencesLabelsResolvedDuplicateInventoryMissing
djangoc9eb16a87e606743,0081,4621,43001022

Every one of the 22 label misses is a reference an intersphinx inventory satisfies at build time, into Python’s own documentation (old-string-formatting, context-managers, tut-packages and kin) or into Sphinx’s generated genindex and modindex, the same tree-only boundary resolution states and pytest’s row hit first. The ten inventory rows are the colon-prefixed intersphinx form, declared unsupported rather than guessed. No label is declared twice.

The first pass read 43 rows as missing, and triage proved twelve of them innocent in three classes, which became the lexer fixes this section’s build carries: an indirect hyperlink target (.. _MySQL manual: MySQL_, and the embedded <Granian_> form) is an alias to another target, not a path; the :file: role is presentation markup that makes no link, distinct from the csv-table option the extractor exists for; and a figure ending .* is Sphinx’s builder-resolved glob. Each shape now extracts nothing, pinned one line at a time in the adapter’s tests.

What remains after the fixes is nine path references that are genuinely dead in the tree: relative directory links like ../middleware/ and ../settings/ in five pages, docs/topics/i18n/translation.txt holding four, left from the documentation structure Django retired when it moved to Sphinx, plus ../url_dispatch/ in the sitemaps reference. Each names a route the current tree does not hold under the spellings a documentation router serves, which is the same class as helix’s one-character break: real, pre-existing, and invisible to a build that never resolves them.

The policy is gone now and the numbers came back the same. Reading source_suffix out of conf.py makes those files documents with nothing staged at all, measured 2026-09-19 on django 862ade3409ce against its own head with the staged index, engine sha256:902c46474348978ecb588e46ddda3d35b3e86450ce54b42ba21b73bb1142ff80. The run before that change reported discovered 14 scanned 9 and seventeen references, all of them external, and passed: nine documents out of a 677-page set, with no hint in the verdict that the other 668 were never opened. The run after reports discovered 691 scanned 686 and 3,057 references, 2,347 of them resolved.

Thirty-one references go missing, and they are the same two classes the staged policy found: the nine dead relative directory links above, and twenty-two :ref: names that belong to another project’s inventory, genindex and modindex into the index Sphinx builds for itself and the rest into CPython’s own pages, which the prose around them usually says outright. The 677 files are 6.5 MB of source together, a fifth of the 32 MiB a snapshot may read. Two other repositories moved by one row each and both were false claims this work removed: numpy’s :doc:`/reference/routines.err` names routines.err.rst, whose name carries a dot of its own, and Sphinx’s own :doc:`/usage/referencing/` carries the trailing slash docname_join drops. The other fifty-two trees in the corpus did not move by one row.

The same ten trees, a third time

Scanned 2026-08-10 on the main build the Django row used, engine sha256:3a7263e876ec5ccd55f3b4899f8189af4567b8b21051bec255d93ffba0257a34, each tree at that day’s default-branch head against the same synthetic empty base as the second pass, depth-one clones. The wall column is new: one process, one scan, measured around the whole invocation on an ordinary development machine. These are the book’s first recorded timings.

RepositoryHeadReferencesMissingAnchorAbsentWall (ms)
helix079a789e8cb03,24910911,086
ripgrep3fce3b5bb023766000347
just4f41f609278e3,2340001,111
mdBookb90df240a318922000400
starship545c0621a2097,510103103014,671
ruff78cad66655dd5,38310281,795
bat2ba8db9c14e539919712132
fdee20f426ddf39611064
hyperfinef12f3d9f86f34800024
alacritty1b2b36a64e888700057

The second pass’s arithmetic predicted 122 real heading anchors, and the current build measures exactly 122, in the same five repositories at the same counts: 103 in starship’s translated pages, 9 in helix, 7 in bat, 2 in ruff, 1 in fd. The absent class fell from 116 to 21. The tracked-ignore answer emptied ruff’s generated-target class whole, alacritty’s one break was fixed upstream between passes, and mdBook stays clean at a newer head. Reference totals move with coverage and with the trees themselves: mdBook’s count drops because discovery now skips its fixture suites, bat’s because its documentation moved.

Every remaining absent row was read at its source line, and none is a resolver defect. helix still carries the one-character ./themes.md break, its community fix (helix-editor/helix#16034) open since July. bat’s twelve are the translated-README 404s of the first study, unchanged. ruff’s eight split three ways: five angle-bracket placeholders in an agent-skill template, one literal teaching example inside a changelog entry, and two broken relative links inside ty’s markdown-based test fixtures under resources/mdtest, a fixture tree the deliberately closed skip list does not name. A maintainer would close all eight, and the rejection classes of the first study still describe them.

The timings say what the architecture promises: a scan is one pass over two snapshots, so every plain tree answers in under two seconds, and Django’s 674 bound documents in the section above answered in 1.1. starship is the outlier its 7,510 references across twenty-two translation mirrors earn, and even that finishes inside fifteen seconds.

Remeasured 2026-08-11 after the anchor lane learned to reuse discovery’s own parse: starship reads 5.7 seconds on the same tree, engine sha256:3423bdfa922c64f9c6ebc7be0fb6242ac1afb5d977b0504f7c0c273f8ac44fd0. The removed cost was a second full parse of every distinct anchor target; what remains is the parse-and-discovery baseline over the mirrors’ 17 MB, a recorded fact with a weekly bench now watching the mirror shape.

Notebook Markdown yield, measured and held

The notebook question was measured on 2026-08-26 before admitting another document format. Ten pinned trees supplied 3,878 exact lowercase .ipynb blobs and 461,907,733 bytes. Every tree with at most 120 notebooks supplied all of them; larger trees supplied 120 paths at evenly spaced indexes after lexicographic sorting, including the first and last. That deterministic sample held 762 blobs and 104,270,321 raw bytes.

RepositoryHeadNotebooksEmptyOver 4 MiBSample
openai/openai-cookbooka7c8782de78827104120
microsoft/ML-For-Beginnersd0d0ea2b2d222,8562240120
jakevdp/PythonDataScienceHandbookd66231454ef713600120
fastai/fastbooke8baa81d89f0440044
tensorflow/docs35e0922e059d18800120
pandas-dev/pandas668be9d6d6771001
matplotlib/matplotlibe519c449e9323003
jupyter/notebook062a2e41d3d2160016
anthropics/claude-cookbooks35f2eec7e448980198
keras-team/keras-io7990430c324626500120

Fourteen sampled blobs were empty, all from the Microsoft tree. The other 748 were nbformat 4.0 through 4.5 documents. Their Markdown source values were joined exactly as the notebook format requires and each cell was passed independently through the production Markdown extractor. No code, output, attachment, kernel, or repository program was interpreted. The extractor accepted all 15,245 cells.

RepositoryValid sampleMarkdown cellsWith referenceWith path-likePath-like occurrencesAll occurrencesOutput share
openai/openai-cookbook1202,041111522111,00640.4%
microsoft/ML-For-Beginners106938104206876590.4%
jakevdp/PythonDataScienceHandbook1203,0001161109261,71193.0%
fastai/fastbook442,429261713734282.5%
tensorflow/docs1203,366120582722,3667.8%
pandas-dev/pandas1861153730.0%
matplotlib/matplotlib325000081.8%
jupyter/notebook161497311494.6%
anthropics/claude-cookbooks981,202663210950273.4%
keras-team/keras-io1202,00911913381,1005.1%

Path-like is deliberately the lower-bound lexical class: no URI scheme, no leading fragment, no attachment: scheme, and no network-path // prefix. It does not count a same-repository forge URL which the resolver could answer. Even under that restriction, 306 of 748 valid notebooks held 1,825 candidates for the existing repository resolver. The full extraction held 7,914 occurrences: 5,836 scheme-bearing or network-path destinations, 231 cell-local fragments, and 22 attachments in addition to those 1,825. The format has real coverage yield rather than merely category adjacency.

The cell bodies are small. The file row covers all 3,878 tree entries; the other two rows cover the sample. Each percentile selects the sorted value at zero-based index round((n - 1) * p / 100), with half-way ranks rounded to even.

Valuep50p95p99Maximum
notebook bytes26,351494,513678,50211,132,496
Markdown-cell bytes2211,5042,78538,276
Markdown-cell lines31941569

Only five tree entries exceed the current 4 MiB document ceiling, and none exceeds 16 MiB. Size is not the reason to refuse the format. Materializing the notebook is: code-cell outputs alone occupied 69,870,711 of the sample’s 100,218,023 whitespace-free JSON bytes, 69.7 percent, before counting execution state or notebook metadata. Outputs were present in 342 notebooks, exceeded half the bytes in 204, and exceeded 90 percent in 106. Five more notebooks carried 15,230,286 bytes of Markdown attachments. A future reader must skip those values while decoding rather than build an owned notebook tree or feed them to the Markdown parser.

Location is the unsatisfied gate. Of 15,245 Markdown cells, 15,189 used an array source and 56 one string; 104 of 78,157 array members themselves held more than one source line. A decoded Markdown span can therefore cross JSON strings, quotes, commas, and escapes. It is not one physical notebook byte span. Cell IDs do not rescue the current contract: only 2,429 of 27,601 cells had one, and the sample contained one duplicate. An index plus an optional valid ID and a cell-local span can name the source honestly, but the current report location has only repository path and physical document span.

The provider UIs cannot consume a substitute. GitHub Check annotations require a path and physical start/end lines, while its rich notebook diff still requires switching to the raw source diff to comment on a line (Checks API, notebook diff limitation). GitLab transforms notebook diffs on commit and compare pages, explicitly not on merge request pages, and offers no code suggestions for notebooks (GitLab notebook diffs). A raw-JSON annotation would be clickable but would name the serialization rather than the Markdown source the finding describes.

Notebook parsing is therefore held, not rejected. Shipping waits for a cell-aware report location and at least one provider or design partner that can use it. That change must be reviewed as a wire migration before the parser: cell source stays isolated, output and metadata stay skipped, empty or malformed notebooks fail visibly, and a cell-local finding is never disguised as a contiguous raw-JSON span.

MyST, Quarto, and Org yield, measured and held

The three adjacent markup formats were measured on 2026-08-27 before admitting another parser. Thirteen pinned trees supplied every exact lowercase source suffix outside the nine built-in excluded directory names: .md in four MyST-family trees, .qmd in four Quarto trees, and .org in five Org trees. There was no sampling. The resulting 1,559 documents held 22,015,633 bytes.

For MyST and Quarto, every document was first passed unchanged through the production Markdown extractor. A second, source-positioned pass counted only the documented dialect forms outside ordinary code fences and escaped examples: MyST directives and roles, and exact Quarto shortcodes and cross-references. Triple-brace Quarto examples are escapes, not shortcodes. Org bracket links and keywords were read with lossless orgize 0.10.0-alpha.10 and cross-checked against Pandoc 3.1.11.1. No code cell, Babel block, plugin, shortcode, included file, or repository program was executed. A repository candidate was then resolved under the format’s documented relative-path rule against the pinned Git tree; generated output and renderer context were never guessed into existence.

MyST

RepositoryHeadDocumentsMarkdown referencesMarkdown path-likeDialect repo targetsExistingInternal roles
jupyter-book/mystmdd41a821e22443121,343255129126728
jupyter-book/jupyter-bookfc05697264cc4285739552
canonical/lxd44a4c0ded1392359663482542531,344
pyOpenSci/python-package-guide10277117d7bd531,11825335350

These files are already discovered as Markdown, and the production adapter accepted all 642. It extracted 4,284 ordinary references, including 895 path-like repository candidates. That is genuine existing coverage, but not MyST coverage. The dialect pass found another 423 repository candidates in {include}, {literalinclude}, {image} and {figure} directives and {doc} and {download} roles; 419 name an entry under the documented source-relative spelling. It also found 2,074 internal role uses, predominantly 2,069 {ref}, {numref}, and {eq} uses whose label and inventory semantics CommonMark does not have. Twenty-one dialect file targets are external.

The four file targets a plain source-relative lookup did not find are the useful boundary, not four defects. Three are deliberate my-file teaching values in MyST’s own reference guide. LXD’s root CONTRIBUTING.md supplies the fourth: doc/contributing.md includes it, and its {doc} role reaches doc/debugging.md in the including Sphinx document’s context. The MyST include contract makes the include path relative to its source, while roles and extension points still require the parsed document context. A suffix alias cannot reproduce that distinction.

Part of that yield now arrives under the Markdown profile where a Sphinx declaration governs the page: the {doc}, {ref}, {term} and {download} roles, and the {image}, {figure}, {include} and {literalinclude} directives, each answered the way its reStructuredText form is. The admission decision below still stands for the dialect as a whole.

Quarto

RepositoryHeadDocumentsMarkdown referencesMarkdown path-likeIncludesCross-referencesExecutable cells
quarto-dev/quarto-webdb4c9fc6a00e5783,9731,97437661322
ropensci-books/targets1d14652363c1206431102261
r-universe-org/docsf5e288ed1e7e25199310015
nasa/ECOSTRESS-Data-Resourcesd9155bc369ac3141000

All 626 .qmd documents were valid input to the production Markdown adapter, which found 4,829 references and 2,017 path-like repository candidates. Amiss currently sees none of them because .qmd is outside the document set. The Quarto-only pass found 376 exact include shortcode targets, every one present in the pinned tree, and 63 cross-reference uses. Initial YAML metadata held 35 file-bearing fields which expanded to 22 concrete bibliography, resource, or body-include values.

That large safe-looking Markdown subset still does not justify binding .qmd to the Markdown adapter. Quarto’s include contract preprocesses an include even inside a code fence and resolves references in the inserted text from the main document rather than from the included file. The same corpus held 598 executable or diagram cells and 452 other renderer shortcodes, including 18 notebook embeds. Cross-reference declarations can also be produced by executed cells. Those are renderer evidence, not repository syntax the engine may simulate.

Org

RepositoryHeadDocumentsBracket linksRelative filesInternalInclude/setupExistingPlain-tree missSource blocks
bzg/org-mode76e4dbe07f934548643707110274
org-roam/org-roam903bd4ec56d216031003059
tecosaur/orgmode.org770916ecb6481621254032642225
SystemCrafters/systemcrafters.github.io6ccb1aa279f71869421920082110752
caiorss/C-Cpp-Notes98ccfc4b0858437,870271039267434,284

The lossless pass found 9,570 double-bracket links: 524 relative file links, 380 internal targets, 8,644 external schemes, one absolute file, and 21 custom or fuzzy shapes. Sixty-one INCLUDE and seventeen SETUPFILE keywords raise the explicit repository-candidate total to 602. Of those, 427 reach an entry by the plain source-relative rule and 175 do not. Pandoc produced 12,628 link or image nodes because its Org reader also recognizes plain external URLs; those add no same-repository coverage and were not substituted for the lossless source counts.

The misses split along renderer boundaries. orgmode.org links generated manual, guide, PDF, and HTML outputs absent from the source tree. System Crafters’ committed live-streams.org is a generated sitemap whose sibling-looking links resolve from the content/live-streams publishing project, not from the file’s physical content parent. The C++ notes repeatedly include an absent theme/style.org, while two encoded file names need Org’s decoding rule. The Org file-link contract, include grammar, and HTML export rewrite are separate semantics; recognizing brackets alone would turn build context into false missing rows.

One Org document, Rosetta_Stone_Translation.org, is 7,154,544 bytes and exceeds the default 4 MiB document ceiling. It carries 2,189 bracket links, three repository candidates, and thirteen source blocks. The other 290 Org documents fit the current byte limit. The size crossing remains a visible per-document refusal and is not a reason to raise the built-in per-document ceiling for a new parser.

Admission decision

The measurement establishes real yield but admits none of the three formats yet. MyST needs a distinct, policy-bindable grammar for roles, directives, labels, and transclusion context; the production dependency graph has no pure-Rust MyST parser pinned to that contract. Quarto needs a dedicated adapter which preserves the measured Markdown subset while representing includes and refusing execution- or plugin-derived targets; calling it Markdown would overstate coverage. Org needs a bounded parser pinned against the GNU Org renderer plus explicit publishing-context evidence; the pure-Rust parser used for measurement is still an alpha, not the production contract.

Unlike notebooks, all three formats can identify their authored constructs with physical byte spans in the source file. None needs a new location shape or wire v2; an admitted adapter would still move the normal additive schema and examples. What is missing is renderer conformance and, for Quarto and Org publishing, trustworthy build context. Shipping waits for that evidence and a provider or design partner that needs the format; executable outputs and plugins remain external evidence in every design.

Editor latency and reverse-impact demand, measured and held

The reverse-impact query had no user population to measure on 2026-08-27. It merged in PR #526 on August 24, after v0.25.0, then the newest release, was cut on August 21. Authenticated GitHub code search for the exact amiss refs phrase returned six matches, all in this repository, and the issue register held no LSP request. Aggregate clone traffic cannot distinguish a command invocation. A source build may have used the query, but there is no observable evidence that an author has, much less that its latency blocks an editor workflow.

The performance half was measured independently rather than inferred from that absence. The release-mode binary at b70ec4164269, engine sha256:c8c667d59c8b960472815999ba45b1fbafcdfaa1adf017c0f238d722a696b1d3, ran on Linux 6.12 on an AMD Ryzen 7 PRO 7840U. Each sample was a new process against a warm page cache, with stdin closed and output written to the null device. One unrecorded warm-up preceded each series. Wall time surrounded process creation and wait4; CPU time and peak resident memory came from that same child. The table uses the median and nearest-rank p95.

Three fully hydrated depth-one SHA-1 clones span the existing workload. Each staged index equaled its base. Django’s local base additionally held the same exact {"path":"docs","kind":"tree","suffix":".txt","adapter":"rst"} policy binding as the earlier yield study, so both sides scanned the adopted document set. Every warm-up produced a complete observe report with no analysis error.

RepositoryUpstream headScanned documentsReferencesReport bytes
sharkdp/hyperfinef12f3d9f86f3348173,805
django/django7576781cc6716833,02816,569,453
starship/starshipcb5415985ca14498,60837,472,463

Startup is amiss --help. A refs row reopens the corresponding complete report and queries its most frequent repository target. A scan row runs the complete staged evaluation and serialization under --profile observe --format json.

SurfaceSamplesWall p50Wall p95CPU p50RSS p50
startup1002.46 ms2.88 ms2.21 ms13.8 MiB
hyperfine refs307.40 ms8.06 ms7.06 ms13.8 MiB
hyperfine scan2045.26 ms48.27 ms44.40 ms13.8 MiB
Django refs15396.58 ms608.42 ms395.90 ms69.7 MiB
Django scan12800.73 ms865.33 ms794.81 ms68.0 MiB
starship refs10579.13 ms644.62 ms571.47 ms154.1 MiB
starship scan74,677.57 ms5,551.65 ms4,682.70 ms129.7 MiB

Process startup is not the bottleneck: even the smallest complete scan costs eighteen times its fresh-process floor, while starship costs about nineteen hundred times it. A resident daemon would save roughly 2.5 ms and leave the material work untouched. Report replay stays subsecond here, but its owned JSON tree makes the 35.7 MiB starship report peak above the full scan. That mirror workload clearly exceeds interactive latency and needs a proved selective or incremental evaluation, not merely a process kept alive.

No editor mode ships from this evidence. Reopen the question only after a release containing refs has an external user or design partner who can name the interaction and repository where latency hurts. Start by measuring a stateless explicit invocation there. Any later worktree or buffer overlay remains observe-only convenience output, never provider-gating evidence; hidden incremental state, a long-lived engine, and background network access remain out until their own correctness and resource model is proved.

Provider service packaging demand audited and held

No operator request justified a deployment package on 2026-08-28. Authenticated GitHub issue and pull-request searches for docker, container image, OCI image, Helm, Kubernetes, self-hosted, provider service, systemd, and deployment platform returned no result in this repository. Exact public code searches for each of amiss-controller-github, amiss-controller-gitlab, and amiss-controller-gitea found only this source tree. The one observable live lane repository is the maintainer-owned amiss-lane-github, whose stated purpose is to exercise a real GitHub App against a deliberate test scenario. It proves that a source-built lane has run; it does not establish demand for an image or an operator’s packaging contract.

The latest release at the time of the audit was v0.25.0. Its platform assets contain the scanner and prober, checksums, and provenance, but no provider service. That is consistent with the supported contract rather than a missing release step: the three service crates are unpublished, and their setup pages direct an operator to build the chosen repository commit with --locked. One commit therefore selects the controller, provider adapter, engine wire, constraint producer, and sealed bootstrap together.

The process boundary is already suitable for an operator-selected supervisor. Each service loads one absolute strict-JSON configuration, validates it offline before startup, binds plain HTTP to a private address behind an operator TLS proxy, exposes private health, readiness, and fixed metrics routes, and drains admitted work on termination. Configuration names pre-created, disjoint scratch, ledger, artifact, and, for webhook lanes, inbox roots. Credentials, webhook keys, execution constraints, bootstrap binaries, and optional controls arrive through bounded regular files. These are the inputs an image would have to mount and the state a rollout would have to preserve.

There is consequently no neutral container definition to add. Choosing a base and target architecture would not answer the runtime user and filesystem ownership, secret injection, TLS edge, private-route exposure, persistent-volume backup, or configuration migration rules. A Helm chart or generated configuration would make even more assumptions, while a separately versioned provider package would break the single-commit closure before 1.0.

No OCI image, service-manager unit, configuration generator, or control plane ships from this audit. Reopen packaging when an operator names the deployment platform, image-signing verification flow, upgrade and rollback contract, and isolation model for secrets, routes, and durable roots. The first package must preserve the same-commit service/bootstrap/engine closure and reuse the release’s existing platform provenance where that operator can verify it; it must not publish the controller crates as a second library product.

What a row must be

A row enters this page only from a recorded run: the machine report kept, the commit pair stated, every raw value sourced from and every derived or classified column traceable to those two artifacts, on a repository that is not this one. The validation phase used the ledger to retain the ten-repository adoption and false-missing evidence; focused PR feedback is now a separately tested product invariant.

Last change: , commit: 81057a62

Retained provider runs

The provider lanes are tested against local HTTP fixtures, and those fixtures are regression tests rather than evidence. A fixture answers the way its author expected the provider to answer. This page retains runs against provider software instead, where the answers come from the provider.

One row is one published verdict: a real instance, a real dedicated reviewer or App, a protected target branch, and a candidate that reached the sealed bootstrap. The provider evidence column names what the provider itself recorded, not what the controller believed.

July 2026

Controller 2dbb0b6, action tree pinned at commit ca5b2b24f3c349553964387ceba62db5b3e87f5e on every instance. The Gitea and Forgejo instances are self-hosted, which is how nearly every deployment of either runs.

ProviderVersionControlProvider evidenceGate commit
GitHubgithub.comruleset activeCheck Run success, conclusion: pass6dbc7eb8c17b
GitHubgithub.comruleset enforcement disabledCheck Run failure, unavailable / authorization-revoked2d584f289f50
Gitea1.27.0protection rule intactreview APPROVED, conclusion: pass4cf4fd91e3e2
Gitea1.27.0direct push re-enabledreview REQUEST_CHANGES, unavailable / authorization-revokedd9496a77e2f5
Forgejo16.0.1protection rule intactreview APPROVED, conclusion: passca697bd509d9
Forgejo16.0.1apply_to_admins: falsereview REQUEST_CHANGES, unavailable / authorization-revoked4fa69ed7d4c5

Each pair of rows holds the candidate content fixed and changes only the control, so the verdict can flip for one reason. Every revocation was restored afterwards and the lane returned to passing the same content.

Drift verdicts from the same instances, where the candidate broke a documented reference and the control stayed intact, are recorded in pull request 131. Both families blocked the drift, refused the merge, and approved the correction.

In July GitLab had no row: the lane’s floor is 19.3 with Ultimate, 19.2.0 was the newest release, and a live 19.2.0-ee confirmed only that the floor refuses the instance.

August 2026

Controller d6d42de, action tree pinned at commit b4b576da872c2e5b7243264919a324e39b7276ad. The instance is gitlab.com itself, running 19.3.0-pre (revision 6ec80fea941) ahead of the self-managed 19.3 release, under an Ultimate trial namespace. The candidate content is held fixed across the pair and only the enforcement control moves.

ProviderVersionControlProvider evidenceGate commit
GitLabgitlab.com 19.3.0-pretrain enforced for all userspolicy job success, train merged73da3cdf0319
GitLabgitlab.com 19.3.0-premerge_train_enforcement: allow_bypasspolicy job failed on 412, train dropped the car270b65505c42

The revocation was restored afterwards and the restored train merged the same content. Drift verdicts from the same instance, where the candidate broke a documented reference and the control stayed intact, are recorded in pull request 332: the drift refused, the completed move approved. Running the lane live found two defects every fixture had agreed with, keeping the July pattern: the documented policy job wrapped its script across YAML lines that policy injection preserves literally, so the book now keeps the command on one physical line, and gitlab.com answers the jobs API with a null source for the policy job, so the adapter now accepts an absent REST source while the signed OIDC job_source claim and the pinned job_config binding continue to state the provenance.

What a row must be

A row enters this page only from a verdict the provider published and still holds: the provider version as the provider reports it, the controller commit that produced the run, the gate commit the verdict names, and the provider’s own record of the verdict. A positive row and a revoked-control row must differ in the control alone. A run against a local HTTP fixture never becomes a row, however faithful the fixture looks.

Last change: , commit: be3a8be9

Roadmap

This page tracks the work ahead: what is being done now, and what stays research. It is not release notes or a promise that anything listed here will ship. The wire contract froze at 1 in August 2026, moved to 2 and then 3 in September 2026, and left this page; the records are in A settled wire and A reason may grow, and the frozen regime’s law lives with The report. Coverage that has landed is described where it works rather than here. The factual boundary of the current product is in Project status, the exit evidence for phases already closed is in Completed phases, and version history is in the changelog.

Research, not committed work

Value claims shipped as the first evaluated kind: Claims states the closed grammar, and everything outside it keeps the unsupported-capability boundary. Typed snippet, inventory, tree, graph, transcript, narrative, and external claims remain research. Persistent acceptance records and governed review state reopen the storage, concurrency, ownership, expiry, and cheapest-bypass problems the stateless scanner avoids, the same problems that killed the ledger design in Provenance.

No claim kind becomes a milestone without design-partner demand, a proof-strength model, evidence that reviewers find it useful, and experiments covering persistence and concurrent branches. Until then these are design vocabulary, not advertised capability. Demand has a place to land: open an issue on the repository naming the claim kind and the repository it would gate, with one drifted example that reference checking cannot catch. The claim-demand issue form asks for those three, and optionally what the claim should have pinned. That register is what this section reads before anything here becomes work.

The permanent boundaries stay in What Amiss is not: no semantic truth verdicts about prose, no repository-executed hooks, no live-network validation inside the engine, no automatic prose rewriting, and no repository-controlled weakening of a required policy.

Renderer-aware adjacent formats

The measured MyST, Quarto, and Org yield establishes useful repository-reference coverage, but does not make any of these formats committed work. They remain three separate future tracks:

  • A MyST adapter would be explicitly policy-bound because .md already means CommonMark/GFM. It must model directives, roles, labels, and transclusion context against a pinned renderer contract rather than reinterpret every Markdown document.
  • A Quarto adapter would preserve the ordinary Markdown subset while modeling Quarto’s main-document include context and static cross-references. Executed cells, notebook embeds, plugins, and renderer-produced declarations remain external evidence; .qmd is not a suffix alias for Markdown.
  • An Org adapter would need lossless physical spans and bounded file-link, include, setup, and search syntax. Publishing roots and generated routes must arrive as sealed renderer evidence; the engine never evaluates Emacs Lisp or Babel, and oversized documents remain visible refusals rather than raising the global ceiling.

Admission still requires design-partner or provider demand and pinned conformance against the official renderer. All three can use the existing physical byte-span location shape, so none is reason to reopen the wire solely to name a source construct.

Editor feedback remains held

The editor-latency measurement found that the reverse-impact query had not yet shipped, with no observable external use and no editor-integration request. Fresh-process startup is about 2.5 ms; complete scans range from 45 ms on Hyperfine to 4.7 seconds on starship’s translation mirrors. Keeping the engine resident would remove the negligible part and retain the expensive one.

An LSP, worktree overlay, or incremental daemon is therefore research, not committed work. Reopen it after a released refs command has a user who can identify a concrete author interaction and repository where latency blocks them. Measure a stateless invocation there first. Any convenience result remains observe-only and cannot satisfy a provider gate; persistent incremental state and background network access need separate proof before they enter the design.

Provider packaging remains demand-gated

The packaging-demand audit found no operator request and no public use of a provider service outside this source tree. The maintainer-owned live GitHub lane proves the source-built service path, not demand for an OCI image, service-manager unit, configuration generator, or control plane.

Packaging remains research until an operator supplies the deployment platform, signing and verification flow, upgrade and rollback expectations, and the isolation model for secrets, routes, and durable state. Any eventual artifact must keep the service, bootstrap, wire, and engine on one chosen commit before 1.0. The unpublished controller crates do not become a separately versioned product to make packaging easier.

Last change: , commit: 6e8029dc

Completed phases

Twelve phases are closed, one page each. A page is a dated exit record rather than live documentation: it states what was true when the phase closed, what each claim defends against, and links the code that has to stay true for the claim to hold. Where a fact has moved on since, the page says so and points at the live chapter that owns it.

Current work is in the Roadmap, the factual boundary of the product is in Project status, and version history is in the changelog.

Validation and hardening asked whether the engine’s claims survive contact with repositories nobody here wrote. Generated contract tables, a bound on embedded code, ten public repositories scanned and kept, zero accepted false explicit-target-missing findings on supported reference shapes as of July 2026 in place of a false-positive rate, one reviewer projection, the event shapes the self-scan actually runs, and the first mutation and fuzz baselines.

Delivery record made a controller that publishes provider verdicts survive crashes, retries, and clock movement without losing a verdict or writing two. One atomic claim, a fenced lease shared with the runner, an authenticated replay lifetime, and a durable record built from ordinary files with fixed lock growth.

Provider-verified controls turned the gate into an object the provider owns and the checked repository cannot forge. One evaluation contract, a sealed bootstrap and runner, exact object acquisition, and three provider lanes, Gitea and Forgejo sharing one, with their gates checked rather than assumed.

Provider operations made a lane deployable, watchable, and restartable without losing work. Offline configuration checks, separate liveness and readiness, ten label-free counters (fourteen since August 2026), a graceful drain, and account-free robustness testing.

Reference coverage answered the four classes the scan ledger had measured and named, and refused a fifth. Heading anchors under twelve renderer rules, router spellings, generated targets read from the repository’s own declarations, AsciiDoc and reStructuredText, and the measurement that killed bare-path inference.

Live provider evidence replaced fixture belief with provider verdicts on every lane. A positive and a revoked-control pair per lane, candidate content fixed so one control moves per flip, github.com and gitlab.com and self-hosted Gitea and Forgejo, and six live-found defects that every fixture had agreed with.

A settled wire froze the report contract at 1 once its three conditions held at once. The wire versioned by its own in-payload field, engine releases decoupled, two minor series quiet under a mechanical tripwire, and the first frozen example retained permanently to hold every later schema in the major to the additive promise. The same page records the move to 2 in September 2026, the first reshape since the freeze.

A reason may grow minted 3 once a closed enumeration was shown to break the additive promise on its next value. A reason is descriptive, so the document row’s unsupported reason and the unsupported-semantics reason became open strings a reader keeps and rewrites byte for byte; kinds, dispositions, statuses, schema names and compatibility stayed closed and still refuse an unfamiliar value.

Projection contracts made exact code, inventory, count, and record-set relationships policy-owned scanner facts. Stable identities, complete-input semantics, bounded difference previews, independent resource meters, and local authoring that never promotes self-asserted data into provider authority.

Authoritative semantic artifacts carried external producer bytes across the provider trust boundary and beyond provider retention. An immutable workflow-artifact plan, exact GitHub acquisition, restart-safe audit artifacts, and one isolated Rustdoc normalizer whose deliberately narrow completeness claim is attached through the generic projection contract.

Offline audit sidecars separated publication and locale facts from the scanner report without weakening either. Closed plan, evidence, and assessment contracts, exact report and product bindings, durable publication replay, page coverage, fallback provenance, and source lineage, with live deployment still stated as operator-gated work.

Cross-repository relation core proved that one operator-owned relation can bind four exact snapshots, survive supersession and restart, and stage idempotent provider outcomes without letting either repository select the other. The provider-neutral core and provider boundaries closed; installation in the live provider binaries did not.

Last change: , commit: 6e8029dc

Validation and hardening

Closed July 2026. The engine was already written; this phase asked whether its claims survive contact with repositories nobody here wrote.

The book’s contract tables are generated, not written

Before this closed, the book mixed four maturity levels on the same page: shipped scanner behavior, the convenience Action, controller components that existed but were not wired to anything, and research ideas. Several mechanical claims had also drifted from the constants they described, which is the exact failure this repository sells a tool to catch.

Dispositions, resource ceilings, finding meanings, the refusal grammar, and the worked examples each exist twice: once in the engine, once on a page. The pages therefore do not keep their own copy. A contract test regenerates each table from the source of truth and compares:

  • documented_profiles_are_generated_from_the_policy_contract rebuilds the disposition table in Profiles and findings from the policy contract.
  • documented_limits_are_generated_from_runtime_constants rebuilds every row of the ceiling table in Limits and refusals from the runtime constants.
  • documented_finding_meanings_are_generated_from_the_engine_text and its error twin compare the meaning sentences on the page against the engine’s own strings.
  • documented_grammar_matches_the_refusal_grammar compares the usage block in Invocation against the grammar the binary prints when it refuses.
  • documented_finding_examples_cover_the_report_schema and all_public_contract_examples_clear_their_schema_and_registered_reader run every published example through the schema and the reader that ships, so an example cannot be aspirational.
  • the_llms_index_names_real_chapters_on_the_published_book resolves every row of the agent index to a chapter file, so the index cannot advertise a page that was renamed away.

The generators are ordinary functions in the same file, so a failure shows the expected table next to the one on the page rather than an assertion that something differs.

A claim that can be generated is generated. A claim that cannot links the code that implements it, which is why so much of the book is link-dense: the link is the check.

Aligned in #46, which also split the factual Project status from the forward-looking Roadmap. Published examples became executable in #60, semantic vectors were enforced in #62, and the markers were made to say what they do in #155. All of it lives in crates/amiss/tests/documentation_contracts/.

Embedded code cannot buy unbounded parse time

The pinned MDX lexer answers one question at every candidate closing brace: can the embedded code end here. Each ask rescans the whole accumulated region. A region that never closes therefore costs time quadratic in its length, and a document full of unterminated regions is a cheap way to make a scanner spend an afternoon on a file nobody will read. The corpus notes recorded the case and left the bound to the resource ceilings, which is a polite way of saying the hole was known and open.

The fix charges the cost where it is spent. A resource, aggregate-embedded-code-evaluation-bytes-per-snapshot, joins the wire enum, both schemas, the floor-tightening map, and the generated limits table:

#![allow(unused)]
fn main() {
aggregate_embedded_code_evaluation_bytes_per_snapshot: 536_870_912,
}

The parse hooks charge every ask against the snapshot’s remaining allowance before the lexical scan reads it. Crossing the ceiling aborts the parse and surfaces as an ordinary RESOURCE_LIMIT_EXCEEDED row carrying the resource triple. It never becomes a claim about the document, because the scanner did not finish reading the document and saying anything about it would be a guess. Spend accumulates across documents rather than resetting per file, so the ceiling is a snapshot budget: a thousand small hostile documents cost the same as one large one. The 64Ki-brace hostile fixture that used to be quadratic now finishes in under 30 milliseconds.

Outside the engine, the convenience Action gained a wall-clock watchdog on the scan step, 120 seconds by default and movable through watchdog-seconds. It is written in plain bash rather than timeout from coreutils, because coreutils is not the same program on all four runner platforms and a watchdog that behaves differently per platform is worse than none. When it fires the engine is terminated, the step says so, and the job fails with no report, which is the correct outcome: no result is not the same as a pass.

Bounded in #81. The meter is crates/amiss-md/src/accounting.rs, the value is in crates/amiss-scan/src/resources.rs, and the watchdog input is in action.yml.

Ten public repositories were scanned and the counts kept

A tool that reports missing references can only be evaluated against repositories it did not grow up with. The first six such scans lived in a session scratchpad, one crash away from gone, which made the adoption argument a memory rather than evidence.

They became a book page instead, The scan ledger, and then grew to ten. One row is one scan: a public repository, a base and candidate commit pair, the observe profile, a release build. Each row records the commit range, references extracted, missing count, advisory rows, changed documentation lines, the historical density per hundred changed lines, and the class of any finding a maintainer would reject. Every raw value comes from the kept machine report or from git diff --numstat over the same commit pair. Derived columns are derived from those two artifacts and never remembered.

The result splits three ways. Four repositories came back spotless. Three carried only real breaks: one introduced in helix, twelve pre-existing in bat across four translated READMEs whose relative links carry the wrong prefix, and one pre-existing in alacritty where the escape-sequence docs moved into the manpage and docs/features.md still links the deleted page. The remaining three mapped systematic non-adoption classes rather than defects.

The page also fixed its own column definitions and a small-denominator rule before more rows accumulated, because a density figure over nine changed lines is noise and would otherwise be quoted as a finding.

Every class the study named has since been answered in the engine rather than left as a caveat, which is Reference coverage. The ledger carries the rescans that measured each one.

Committed in #82 with six rows, grown to ten in #86.

A false missing target is a bug, not a statistic

A checker that reports references which actually resolve teaches maintainers to ignore it, and a muted check is worse than no check because it also consumed the attention it was meant to protect. The usual industry answer is a false-positive rate. This project does not have one for explicit-target-missing on the reference shapes it supports.

A false explicit-target-missing on a supported reference is a resolver defect. It gets a pinned test, and the accepted count on supported shapes was zero when the phase closed in July 2026. That distinction is what makes The scan ledger readable: a nonzero missing count in a row is either a real break or a named class, never a tolerated error margin, so nobody has to guess which.

Holding that line costs a large test surface, because every supported reference shape needs a case. crates/amiss-scan/tests/resolve/ runs to around 1,450 lines for that reason, covering component splitting in RFC order, line-selection bounds as structural outcomes, LFS pointer targets, exact target digests, directories resolved identically through a commit and through the index, paths compared as bytes with no case folding and no normalization, and the GitHub, GitLab, and Gitea URL dialects each resolved against the tree rather than pattern-matched.

The same rule is why a GitHub URL needs the whole trusted chain before it resolves, pinned by github_urls_need_the_whole_trusted_chain: guessing that a URL belongs to this repository, and reporting it missing when the guess is wrong, would be the same defect wearing a different hat.

Review feedback is grouped, ordered, and bounded

Before this, consumers reshaped raw findings themselves. The command line did it one way, the Action another. That leaked the engine’s internal taxonomy into review, duplicated the classification logic in two places that could disagree, and let harmless inventory rows crowd out the two lines a reviewer needed to read.

The engine now owns one deterministic reviewer projection. feedback groups review work by the target it concerns and classifies each item as Fix, Check, or Existing. Classification derives from correlation, attribution, and location metadata rather than from a match over FindingKind, so adding a finding kind does not mean editing the reviewer’s view to keep it sensible.

The ordering and the caps are the part that matters in practice. Fixes come before Checks, so what must change is read first. Existing findings never take a pull-request annotation, because annotating code the author did not touch is precisely how a check earns a mute. Scan errors stay separate from findings, since “the run did not complete” is a different statement from “this reference is broken”. The human and Action views cap at ten combined items, and only candidate-located displayed Fixes become annotations. Nothing is dropped: every exact finding stays in the JSON report, which is the artifact for tooling, while the reviewer view is for people.

An incomplete run reports feedback as explicitly unavailable rather than as an empty list, because an empty list reads as “nothing to do” and that would be a lie about a run that failed.

Shipped in #95, which also made removed references recorded facts, and rendered in crates/amiss-scan/src/feedback.rs. The annotation boundary is the annotations input in action.yml; annotation flooding was addressed in #68.

The self-scan runs the event shapes it claims to support

This repository gates itself with its own action under enforce, which is only evidence if the self-scan exercises the event shapes real users hit. A gate that has only ever seen an ordinary push proves nothing about a shallow checkout, and the failure mode there is not a crash: a scan with a truncated history silently compares against the wrong base.

Recorded runs cover push, same-repository pull request, depth-two shallow checkout, and the staged-index path, the last of which runs --base "$(git rev-parse HEAD^)" --index against the checkout’s clean index on every CI run. Two corrections were needed to make those rows mean anything. The self-scan first had to fetch full history, since a shallow clone gave it a base it could not resolve. Then the pull-request base had to be derived from the merge commit itself rather than from the event payload, because the payload’s base is where the branch started, not what the merge would actually compare against.

The fork path deliberately uses the same unprivileged pull-request workflow rather than a second privileged one, so there is no second code path that only forks take and only forks can break.

Fork and merge-group runs are not retained as phase gates, and the reason is recorded rather than hidden: as of July 2026 GitHub offers no merge queue to this public, user-owned repository. The merge_group trigger and its event mapping stay in place so a repository that does have a queue is covered. That is a claim about readiness, not about testing, and the distinction is the point.

The job is self-scan in .github/workflows/ci.yml. History depth fixed in #70, the shallow and staged-index rows recorded in #85, and the base derived from the merge commit in #88.

Mutation and fuzz runs are installed with recorded baselines

A suite that only ever runs green says little about whether it would catch a regression. Two non-gating runs answer that question separately from the gates: a mutation run and a nightly coverage-guided fuzz run.

The first mutation run recorded 2,728 mutants with 664 missed on 2026-07-18, and it paid for itself immediately by showing the release-manifest laws untested, which 323 lines of tests then covered. That is the intended use of a mutation run: not a score, a list of places where a lie would go unnoticed.

Both runs are deliberately non-gating. A weekly signal converted into a merge gate becomes a flaky merge gate, and a fuzz run that must finish before a merge is a fuzz run that stops looking hard.

The baseline has moved since, and the current reading lives with the tooling that produces it in Development rather than here: the sweep of 2026-07-28 measured 6,523 mutants over both workspaces. The lanes were rebuilt around that scale, so a pull request now measures only the mutants its own diff reaches.

Installed and excluded from the fixtures crate in #83; the untested manifest laws the first run exposed were covered in #87. The schedules are .github/workflows/mutants.yml and .github/workflows/fuzz-long.yml.

Last change: , commit: 437f65fd

Delivery record

Closed July 2026. A controller that publishes provider verdicts has to survive crashes, retries, and clock movement without losing a verdict or writing two. The lifecycle arrived in #98, #100, and #103, was finished in #105, and was bounded in #118.

Claim, lease, result, and completion are one contract

The controller before this could preserve an evaluation ID across retries and little else. It had no way to represent two workers owning the same delivery, and no way to represent the window between finishing an evaluation and publishing it, which is exactly where a crash costs you either a lost verdict or a second one. A stale worker could not be rejected, because there was nothing to reject it with, and a retry had no immutable result to resume from.

DeliveryLedger replaced that with one atomic claim whose answer is the whole coordination contract:

#![allow(unused)]
fn main() {
pub enum DeliveryClaim {
    Execute(DeliveryLease),
    Publish(StagedPublication),
    Busy {
        evaluation_id: OpaqueId,
        retry_at_unix_millis: i64,
    },
    Duplicate {
        evaluation_id: OpaqueId,
    },
    BindingConflict,
}
}

Execute grants ownership. Publish hands back a result a previous owner already froze, so the retry publishes rather than recomputes. Busy says someone else holds it and when to come back. Duplicate is reserved for terminal, durably completed work, and BindingConflict for a delivery whose identity does not match what the record already holds under that key.

Ownership is fenced rather than timed. The lease carries a monotonic fence, and the deadline in it is advisory for a reason worth repeating:

#![allow(unused)]
fn main() {
/// Advisory deadline; only the ledger transaction decides ownership.
pub expires_at_unix_millis: i64,
}

A worker that believes its lease is live is not the authority on that. The transaction is. That one decision removes the class of bug where two processes disagree about a clock and both publish.

Three rules follow and are pinned by test. An owner whose lease has expired cannot save a new result. A result saved before expiry stays publishable on retry, because the work was real and the clock running out later does not make it wrong. A retained completion marker is repeatable without granting new work, so a redelivery is answered from the record instead of evaluated again.

The contract is controller/src/orchestration/ledger.rs and the operator view is Controller delivery.

The record and the runner share one lease

Two components with two ideas of who owns a delivery will eventually both act on it, and the result is either a duplicate verdict or a stale one overwriting a current one. The usual fix is a longer timeout, which converts a common bug into a rare one and makes it harder to reproduce.

The controller record and the runner share a single lease contract instead. The runner renews before its relative lease window closes rather than after, and losing ownership stops the run rather than letting it continue to publication. Ownership loss is a stop condition, not a race to finish first. A worker that was paused long enough to lose its lease finds out at the next transaction, and the work it was doing is dropped rather than published late.

Every accepted delivery carries a replay lifetime

Replay suppression needs an end. Keep every delivery identity forever and the record grows without bound. Forget one too early and a signed request that is still valid becomes replayable, which is a security hole wearing the costume of a cleanup job. The question is who decides when forgetting is safe, and the answer cannot be whoever is asking.

Trusted ingress stamps each accepted delivery with a lifetime at admission, derived from what the request itself can prove. A delivery authenticated by exact body, or by a scheme that only proves replay identity, is permanent, because nothing in it says when it stops being valid and guessing an end would be inventing a fact. A delivery carrying an authenticated ID and issue time gets a fixed end computed from the controller’s signed-age and queue ceilings, so the end comes from configuration the operator set rather than from the sender.

A route may narrow freshness beyond that. A route may not extend the lifetime already stored. That asymmetry is the whole point: the strict direction is always available, the permissive one never is, so no per-route setting can quietly reopen a replay window the controller closed.

The Gitea family is the concrete case. Its native webhook signature covers the body and nothing else, with no timestamp anywhere in the delivery, so its replay markers are permanent and the provider page says so rather than implying a window that does not exist. Ingress is controller/src/ingress.rs and the signature schemes are controller/src/webhook/.

The record is ordinary files, not a database

A controller that needs SQL to hold its own delivery record inherits a service to run, back up, patch, and secure, and it inherits it inside the trust boundary. For a component whose whole job is to be harder to lie to than a CI job, that is a poor trade. FileLedger implements the entire contract with ordinary files, cross-process advisory locks, and atomic replacement.

The root carries its own configuration. .amiss-root.state fixes the record cap and the replay window and preserves a high-water clock, so reopening a root with different limits, with capacity missing, or with damage that was never marked, fails closed instead of proceeding on assumptions. Two processes cannot disagree about the shape of the record, because the record states its shape.

Capacity lives in a separate checksummed frame, .amiss-capacity.state, holding a slot count that never understates use plus one exact pending key. The asymmetry is deliberate: a count that is too high refuses a row that would have fit, which costs a retry, while a count that is too low overfills the root, which costs the bound. The pending key means an addition interrupted by a crash can be settled from that one row rather than by rebuilding the count from the directory.

Deletion is batched. One recovery marker and one final count write replace syncing the bookkeeping for every row, so removing a thousand ended rows is one durable decision rather than a thousand. Reading a root a previous version wrote is a real case rather than a hypothetical, so v0.9 metadata migrates in place. The store is controller/src/file_ledger/ and the operator view is The file ledger.

Lock growth is fixed and admission does not scan

A record that takes a lock per row, or counts the directory on every insert, gets slower exactly as it gets busier. On v0.9.0 that was measurable: admitting one new row into a root holding 100,000 retained entries took about 57.5 milliseconds, because admission counted what was already there.

The lock set is fixed and small. One maintenance lock, one admission lock, one clock lock, and at most 256 lazily created row-lock shards:

.amiss-root.state        .amiss-maintenance.lock
.amiss-capacity.state    .amiss-admission.lock
.amiss-clock.lock        .amiss-row-7a.lock

Row locks are named by one hex byte of the row key, so the count is bounded by the shard space rather than by the number of rows, and a busy root creates the same 256 files a quiet one does.

Admission stops scanning. The checksummed capacity frame answers “is there room” without reading the directory, and the same measurement fell from about 57.5 milliseconds to about 0.25 milliseconds, with a full-capacity rejection at about 0.085 milliseconds. New identities are admitted under the configured cap while work already inside the cap is allowed to finish, so filling up refuses new arrivals rather than stalling what is already running.

FileLedgerRoot moved preparation and cleanup out of the request path. A service prepares and cleans the root once, then creates independent fenced owner sessions without repeating startup maintenance per request. Each new row also takes a fresh random evaluation suffix, so an old retry cannot match a later row that happens to reuse a key after a safe deletion.

The measurement is not a merge gate, because a machine-specific timing threshold is a flaky test wearing a stopwatch. A weekly release-mode run records admission, full rejection, and cleanup separately at 1,000, 10,000, 50,000, and 100,000 retained entries, and the numbers are kept as evidence rather than asserted. The run is .github/workflows/bench.yml.

A row is one bounded state file and, briefly, a report

Unbounded per-row storage is how a broken or hostile provider fills a disk, and a partial write is how a restart resumes into a state that never existed. Both are ordinary failures, so the row format assumes them.

Each row is one bounded state file, plus one bounded report file for as long as the report is needed and no longer. Neither can grow past its ceiling, so a row’s cost is known before it is written rather than discovered afterwards.

Write order carries the invariant. Saving a result writes the report before the state that names it, so no state file ever points at a report that is not there. Completion writes done before removing the report, so nothing removes the evidence while a claim on it could still be made. A crash between any two steps leaves a state the next open can recognize, which is the property that matters: not “this cannot be interrupted”, but “an interruption is legible”.

Both opening the root and explicit cleanup remove dead reports and known atomic-write leftovers, so the debris of an interrupted write is collected by ordinary operation rather than by an administrator noticing. The format is controller/src/file_ledger/format/.

Cleanup removes only what is safe to forget

Cleanup is where a durable record usually gets quietly wrong. Too eager and it reopens a replay window or deletes work in progress. Too shy and the root grows until admission starts refusing real deliveries.

The rule is narrow on purpose: only completed rows whose authenticated replay lifetime has ended are removed. Permanent completion markers stay, because a delivery with no provable issue time has no safe end. Running work stays. Saved results stay, because a result frozen before its owner expired is still the answer for that delivery.

Clock movement is the subtle case. A local clock that jumps backwards would make expired work look live again, so the persisted high-water clock in the root metadata refuses to go backwards and a rollback cannot reopen anything.

The pinned cases are the honest measure of the rule: the inclusive end of the window, clock rollback, permanent retention, preservation of running and saved work, fixed lock growth, behavior on a full root, recovery from an interrupted capacity update, and cleanup’s own fail-closed root scan. Cleanup that cannot read the root does nothing rather than assuming the root is empty, which is the difference between a maintenance job and a data-loss incident. The transitions are controller/src/file_ledger/transitions/.

Last change: , commit: 2d578ad8

Provider-verified controls

Closed July 2026. The engine report is self-asserted, so the gate had to become an object the provider owns and the checked repository cannot forge. Most of it landed in #107; the corrections that live instances forced came later, in #131 and #132.

One evaluation contract, not one per provider

Two problems shared one cause. The contract named provider-specific identity types, so describing GitLab meant growing a second shape and a provider enum sat in the middle of the trust boundary, where adding a provider means editing everything that matches on it. Separately, one ref was doing two jobs: the ref used to resolve URLs and the protected branch that controls apply to are different things, and conflating them means a check can verify one branch while the merge rule guards another.

The rolling contract separates the source ref from the protected target ref, and a frozen controller evaluation binds provider, integration, repository, URL dialect, change, refs, commits, trees, provider gate, check plan, execution limits, and trusted time, none of which requires knowing which provider is speaking. Providers differ in how those facts are obtained, not in what the evaluation says.

The change was mostly deletion. Opening the execution-constraint identity took 291 lines added against 169 removed; rolling the contracts forward removed 10,549 lines across 136 files while adding 2,499. Forge-shaped variants had accumulated in the wire types, the schemas, the examples, and the goldens, and most of the work was proving they were redundant rather than writing something new.

Opened in #57 and rolled forward in #58. The types are crates/amiss-wire/src/requests.rs.

The controller ships as source, not as a crate

The engine is published to crates.io and has no network capability at all, which is checked rather than claimed: a separate dependency policy bans HTTP clients, async runtimes, and socket crates from the engine’s graph, with reasons written into the file.

{ crate = "reqwest", reason = "the engine has no HTTP client" },
{ crate = "tokio", reason = "the engine has no async runtime and no sockets" },
{ crate = "socket2", reason = "the engine opens no sockets" },

The controller does have network capability, credentials, and provider tokens. Publishing it as a convenient dependency would put all of that one cargo add away from anyone who wanted the scanner, and would make the scanner’s dependency graph the union of both. So the controller/ workspace stays unpublished and source-built. An operator who wants a provider lane builds it from a commit they chose.

Inside, provider differences live in small crates rather than in a closed provider enum: provider-neutral traits and the orchestrator, a bounded ingress gate, a rotating key ring, signed-webhook checks, GitLab OIDC checks, and one adapter crate per provider family. A fourth provider is a new crate, not a new arm in every match statement.

Introduced with the controller foundation in #98 and folded into a single workspace in #123, which kept the two dependency graphs separate while removing the duplication two independent workspaces had caused: 3,352 lines added against 4,474 removed.

The bootstrap takes canonical documents and nothing else

The bootstrap is the trusted edge. A provider lane acquires objects, then hands them to this binary, and whatever it accepts is what the engine ends up believing. Every format it tolerates is a format an attacker may write.

It accepts three canonical documents, checks their required bindings, and passes their exact bytes to the verified engine in one closed input frame: the evaluation, the snapshot, and the controls. Bytes in, bytes out, with no reformatting step in between where a difference could hide. The documents have published schemas rather than being an internal convention, so an operator can validate what a lane will present before presenting it. The same wire library produces canonical execution limits and trusted-time statements, so the documents a lane presents come from the code that validates them rather than from a second implementation that agrees until it does not.

The executable itself is bounded at 33,554,432 bytes:

#![allow(unused)]
fn main() {
pub const BOOTSTRAP_EXECUTABLE_BYTES: u64 = 33_554_432;
}

That bound is load-bearing in a way that only shows up in practice. A fixture binary that linked one crate too many crossed it during this project’s own lane testing and every run refused with Unavailable rather than running an unbounded executable, which is the ceiling doing its job on the person who set it.

The crate has shipped since #1; it learned these documents with the sealed evaluation foundation in #98. It is crates/amiss-bootstrap/.

Authenticate first, save the raw bytes, then acknowledge

Two ordering mistakes are easy to make in a webhook receiver, and both are quiet. Parse before authenticating, and a parser meets hostile input for free. Acknowledge before storing, and a restart in the wrong millisecond loses a delivery the provider will never send again.

The receiver authenticates before admission and saves the exact raw delivery before acknowledging it. Raw means the bytes that were signed, not a re-serialized version of them, because a signature covers bytes and anything else is a different document.

The inbox is ordinary files, like the delivery record it feeds, and carries the properties that make a queue survivable: it outlives a restart, enforces both row and byte capacity, renews ownership while the controller works, retries temporary provider failures rather than treating them as verdicts, and removes the raw bytes only once the delivery ledger has completed. That last ordering means the bytes outlive every state that might still need them.

The listener is bounded before any of that: a fixed body ceiling, a fixed header count and header byte budget, and a delivery permit taken before the body is read and held through durable admission, so the memory a hostile sender can commit is decided by configuration rather than by the sender.

Completed in #107. The receiver and inbox are controller/service/src/.

Objects are fetched by exact name under fixed limits

Fetching a branch and trusting what arrives lets the remote choose what gets scanned. Fetching without limits lets it choose how much memory the controller uses. Both are the same mistake: letting the answer decide the question.

Acquisition speaks Git protocol v2 with exact authenticated SHA-1 wants for the repository commit and the pinned action commit, so the remote answers a question rather than proposing an answer. One deadline covers network receipt and validation together, because a deadline that stops at the socket lets a slow validator hang after a fast download.

The pack limits are fixed constants, not configuration, and every one of them fails closed:

#![allow(unused)]
fn main() {
pack_bytes: 2_147_483_648,
objects: 2_000_000,
object_bytes: 134_217_728,
inflated_bytes: 4_294_967_296,
resolved_bytes: 4_294_967_296,
delta_depth: 128,
}

REF_DELTA is rejected outright, since a delta against an object outside the pack is a request to resolve something the sender did not send. Pack indexing uses one thread, which trades throughput for a bounded and reproducible cost profile. The protocol client is controller/git/src/protocol.rs.

The runner seals the job it supervises

Between acquiring objects and trusting a result there is a process to start. A process is where inherited environment variables, inherited file descriptors, and leftover child processes turn into a trust problem, and none of those show up in a happy-path test.

The runner rechecks the acquired repository and action roots rather than trusting that acquisition put the right things there, derives a sealed job, and checks the pinned bootstrap against its expected digest. It prepares private inputs, clears inherited environment and streams, and supervises one cross-platform process tree. The controller owns the output handles, so the child writes into handles it did not open and cannot redirect.

Reading the result is equally distrustful. Wall-clock and lease limits both apply, whichever ends first. The output tree is proven empty before the run, so a leftover file cannot be read as this run’s answer. The report is bounded, and an incomplete or malformed result is rejected rather than parsed optimistically.

The pinned failures are the shape of the guarantee: wrong roots, bootstrap tampering, bad output, missing output, oversize output, timeout, heartbeat loss, and live descendants. That last one matters most in practice, because a child that outlives its parent is how a runner leaks work into the next job.

Sealed in #106. The runner is controller/src/bootstrap_runner.rs and acquisition is controller/src/acquiring_runner.rs.

The GitHub lane runs one repository end to end

A provider lane is only meaningful if an operator can stand the whole thing up: one repository, one App installation, one protected branch, and a service that fails loudly when its configuration is wrong rather than at the first webhook.

The source-built GitHub service completes that lane on GitHub.com or a compatible GHES release. Strict JSON loads the App key, the rotating webhook secrets, the external controls, the execution constraint, the bootstrap, and separate private state roots, refusing any field it does not recognize instead of ignoring it. An unknown key in a trust-boundary configuration is either a typo or an attempt, and neither should be silently dropped.

The listener speaks plaintext by design and is deployed behind an operator-owned TLS and connection-limit boundary. That is stated rather than assumed, because a service that pretends to terminate TLS while sitting behind a proxy that also does invites exactly one confusion.

The App identity is what makes the gate real: the required status is bound to the App’s integration id in the repository ruleset, so no other actor can post the check that satisfies it. The service is controller/github-service/ and the setup is GitHub.

The GitHub source accepts four events and binds them

Accepting every webhook a provider offers widens the attack surface for nothing. Most events cannot change what a documentation check would conclude, and each one accepted is another payload shape that has to be parsed safely.

The source accepts signed opened, reopened, and synchronize pull-request events:

#![allow(unused)]
fn main() {
const SUPPORTED_ACTIONS: [&str; 3] = ["opened", "reopened", "synchronize"];
}

edited is accepted only when the signed payload says the base branch changed, because that is the one edit that moves what the check is about. An edited title is not a new evaluation.

Admission then binds the configured repository and target, so a correctly signed event for another repository is refused rather than evaluated. Signature validity answers “did GitHub send this”, not “is this mine”.

After admission the App client refreshes the exact repository, pull request, ref, commit, tree, and test-merge facts from the API rather than trusting the payload’s copy of them, and requires a strict active status rule whose context is bound to that App. It refreshes again before saving the result, because the state that decides a verdict is the state at publication, not the state when the webhook arrived. The source is controller/github/src/lib.rs.

The verdict lands on the commit GitHub actually merges

A check attached to the head commit describes the branch. A merge queue merges something else, the test-merge commit, and a status on that commit takes precedence over the head. Publishing to the wrong one produces a green branch and an unchecked merge, which is the failure mode worth the most care in the whole lane.

Publication attaches success, failure, or cancelled to GitHub’s authoritative test-merge commit. The summary binds the gate, provider run, refs, commits, trees, plan, execution constraint, report digest, and a stable unavailable reason, so the Check Run says what was evaluated rather than only how it ended. A reader who distrusts the verdict can reproduce the inputs from the check itself.

Idempotency is honest about its limit. The evaluation ID reconciles one exact visible retry, so an ordinary retry updates rather than duplicates. A create that GitHub accepted but whose reply was lost can still leave a duplicate, because GitHub and the local ledger do not share a transaction, and no amount of local bookkeeping fixes that. The page says so rather than implying exactly-once.

A final pull-request refresh turns an out-of-order publication into a no-op once its staged head, base, refs, or gate is no longer current, so slow work cannot write a stale verdict onto a newer gate. Publication is controller/github/src/live/.

The GitLab lane runs as a policy job on the merge train

GitLab has no App identity to own a status, and any project member can edit a job the project defines. A gate the checked project can edit is not a gate, so the usual shape, a CI job that posts its own result, does not survive the threat model.

The lane uses a pipeline execution policy owned outside the checked project, which injects the job into every enforced merge train. The checked project cannot remove it, rewrite it, or skip it. The service then authenticates the job’s short-lived OIDC token and binds its policy project and commit, job and pipeline, runner, merge request, repository, and exact train-result commit before trusting any provider state at all. Each of those is a way the job could be someone else’s, and the binding is what makes the token mean this run rather than any run.

The lane requires GitLab 19.3 or newer with Ultimate, because enforced merge trains are what make the policy job unavoidable, and they are generally available from 19.3. No live run is recorded yet: as of July 2026 the newest release is 19.2.0, so no supported instance exists to run. That changed in August 2026, when gitlab.com ran 19.3.0-pre and the lane closed on live evidence; the rows are in Retained provider runs.

The service is controller/gitlab-service/, the OIDC checks are controller/gitlab/src/oidc.rs, and the setup is GitLab.

The GitLab gate refuses anything but the exact saved pass

The policy job is a synchronous endpoint: it asks the service a question and merges on the answer. Anything other than a proven pass returning success turns the whole lane into decoration.

Refresh requires the configured merge method, exactly two train parents, an active policy job, a protected target branch with no push or bypass path, and merge-train enforcement for all users. Each is a way the shape being gated could differ from the shape that was verified. Two train parents in particular is what makes the train result the thing that merges rather than some other commit that happens to be nearby.

Then the endpoint refuses everything except the exact saved pass. Success is the exact HTTP 204 and nothing else. Block, unavailable, duplicate, expired, replayed, and changed state all keep the policy job failed. There is no “probably fine” state, and no path where a missing answer reads as an affirmative one. The rules are controller/gitlab/src/live/refresh.rs.

The Gitea family lane publishes through a dedicated reviewer

Gitea and Forgejo have no App identity and no first-class status owner. The only gate available is an approval from an account nobody else controls, which makes the account itself a trust anchor: whoever can act as that reviewer can satisfy the gate without Amiss, and the provider page says so in those words.

The service authenticates the native exact-body HMAC, refreshes the pull request, commits, trees, effective branch rule, and reviewer identity, then publishes an approval or a request for changes as that one account. It supports Gitea 1.27 or newer and Forgejo 16 or newer.

Getting it to work against real instances took four corrections that no fixture had caught, because each fixture was written to the API’s documentation rather than its behavior. Both families answer /git/commits/{sha} with the commit’s own name in the commit’s tree field:

$ curl .../git/commits/436a6f35fd89b32d8661c6d7e12ba19960dfd841
  sha        : 436a6f35fd89b32d8661c6d7e12ba19960dfd841
  commit.tree: 436a6f35fd89b32d8661c6d7e12ba19960dfd841
$ git cat-file -p 436a6f35
  tree 5c1c95daa0e57e7a46ad6937d4b1515e0b5ff43f

No route on either family states the tree of a commit, so trees now come from fetched Git objects through the same resolver the GitLab lane already used. Forgejo also sends one signature under two spellings, X-Forgejo-Signature and X-Gitea-Signature, and the header reader treated the second spelling as ambiguity and answered 401 to every real Forgejo delivery. A transient mergeable: false, which Gitea reports for a second or two while it recomputes a merge, was being read as a terminal verdict. And a control revoked between staging and publication left the lane publishing nothing at all.

The service is controller/gitea-service/ and the setup is Gitea and Forgejo.

The Gitea family gate is checked, not assumed

An approval gates a merge only if the branch rule actually requires that approval and closes every other way in. Those are separate facts, reported separately, and either one missing makes the approval decorative.

The gate requires one approval restricted to the dedicated reviewer, closed direct-push and bypass paths, stale and rejected review blocking, an up-to-date pull request, and administrator enforcement. The adapter checks the distinct Gitea and Forgejo capability shapes rather than guessing which forge it is talking to from headers, because the two report overlapping fields with different meanings and a wrong guess produces a confident wrong answer.

Two facts about this only surfaced against live instances. Reading the rule needs repository administrator access, not write: below that, /branch_protections/{rule} answers 403 and the branch route leaves effective_branch_protection_name empty, so the lane cannot read the rule it is required to check. The documentation said write access, which cannot work. And the gate is verifiable in both directions now: with the rule intact the lane approves, with direct push re-enabled it publishes unavailable / authorization-revoked, and restoring the rule returns it to approving the same content. The checks are controller/gitea/src/live/refresh.rs.

The lanes are tested through, and against, themselves

A lane test that only walks the happy path proves the pieces connect. It says nothing about the cases a gate exists for, and those cases are the product.

End-to-end and focused tests carry a signed delivery through authentication, durable admission, provider refresh, the runner, the provider gate, completion, and replay suppression. The negative list is the real coverage: wrong provider, repository, target, runner, policy, reviewer, commit, and tree; changed bootstrap or merge rule; expiry and replay; missing output and timeout; malformed or tampered input and state; capacity and restart; lost ownership; ref or gate drift; oversized and malformed packs; REF_DELTA; excessive delta depth; and conflicting provider evidence.

The limit of that coverage is worth recording, because live instances found it. Every double in these suites answers the way the provider’s documentation says it will. Gitea’s test double returned a tree name distinct from its commit name, which real Gitea never does. The Forgejo lane test sent one signature header, which real Forgejo never does. Both suites passed while neither provider could have worked, and no amount of adding cases to a double that agrees with the code would have found it.

What did find it was standing up real instances, which is why Retained provider runs exists as a separate kind of evidence rather than as more tests. The fixtures are still the right regression net: they run in seconds, they cover the negative cases exhaustively, and they catch a change that breaks a lane. They just cannot tell you the provider was never like that. The suites live under each service, such as controller/github-service/tests/lane/.

Provider evidence lives in the provider, not in the report

A report that says it was verified is a report asserting its own trustworthiness. Anything that can produce the report can produce the claim, so the field would be worth exactly nothing and would read as though it were worth something. That is worse than omitting it.

So the evidence is an object the provider owns and the checked repository cannot forge: the App-owned Check Run, the protected GitLab policy job, or the dedicated Gitea-family review, each paired with the merge rule that makes it necessary. The engine report stays exactly what it was, self-asserted, with no provider signature and no provider_verified field. Nothing was added to it, and that decision is the one worth recording: the natural move when shipping provider verification is to stamp the artifact, and the stamp would have been a lie.

Each provider page states its own commit or tree freshness limit, retry behavior, rotation rules, and full trust boundary, including which accounts and keys can satisfy the gate without Amiss. A trust boundary that is not written down is a trust boundary nobody checked.

Live runs for GitHub, Gitea 1.27.0, and Forgejo 16.0.1, in both directions, are in Retained provider runs, where the GitLab rows joined them in August 2026. The lanes are Provider-verified controls.

Last change: , commit: 437f65fd

Provider operations

Closed July 2026. A lane that cannot be deployed, watched, or restarted without losing work is not finished, whatever its verdicts say. Five claims, all added in #121 and #122.

Every provider binary can check its configuration offline

Before this, an operator learned that a credential path was wrong by starting the service and waiting for a webhook. On GitHub it was worse: private-key and API-client validation happened at runtime, so a bad key surfaced during the first real delivery.

Every provider binary takes --check with an absolute configuration path. It runs the same strict loader startup runs, over the same local credentials, trust anchors, controls, execution constraint, bootstrap, and path layout, and validates the execution constraint against the host it is running on. GitHub also constructs its App client during that load. Then it exits, before service runtime, before mutable state is opened, before the bootstrap is executed, and before any provider I/O.

It is the real loader rather than a second implementation, which is the only version of this feature worth having. A preflight that agrees with a configuration the service would reject costs trust rather than earning it.

What it does not claim is written down: not provider reachability, not credential permissions, not merge-rule correctness, not service readiness, and not retained live-provider evidence. It is account-free on purpose, so it can run in a pipeline holding no provider credentials at all.

The entry points are each service’s main.rs, such as controller/github-service/src/main.rs, and each service’s tests/config.rs covers the family.

Liveness and readiness answer different questions

An operator could not tell a live process from a serving one. /healthz answered before local state was open, so an orchestrator would route deliveries into a process that could not accept them, and neither restart nor credential rotation had an observable boundary.

The private listener separates the two. /healthz answers whether the process is running. /readyz answers whether admission can currently accept a delivery, which is what a load balancer is actually asking. While unready, provider work is refused with 503 rather than accepted and dropped, so the provider retries into a service that will still be there.

Lifecycle transitions are written to stderr as one redacted JSON object each, and the schema is deliberately narrow: schema, level, event, and component, nothing else. A log line that can carry a repository name or a delivery identity can echo request data into an operator’s aggregator, so this one cannot.

One operator consequence is stated rather than left implied: none of the three private endpoints is authenticated. The listener belongs on loopback or an operator network, with only the provider POST path published through a proxy. An unauthenticated readiness endpoint on a public interface is a free liveness oracle for anyone who wants one. The endpoints are controller/service/src/probe.rs.

Ten counters, no labels, no cardinality surprise

Ten was the count when this phase closed. Four external_* verdict tallies joined in August 2026, so the live set is fourteen; Project status carries the current number.

Metrics with provider-supplied labels let a provider decide how much memory the registry uses, which turns monitoring into a denial-of-service surface. A metric set that grows as the code grows becomes a compatibility surface nobody agreed to maintain.

/metrics exposes exactly ten fixed, label-free, process-local counters under the amiss_controller prefix:

#![allow(unused)]
fn main() {
pub provider_requests: Counter,
pub provider_acceptances: Counter,
pub provider_refusals: Counter,
pub provider_unavailable: Counter,
pub delivery_attempts: Counter,
pub delivery_completions: Counter,
pub delivery_retries: Counter,
pub delivery_discards: Counter,
pub maintenance_runs: Counter,
pub maintenance_removals: Counter,
}

The set cannot grow from a repository, an identity, or a result, so nothing a provider sends can add a series. Lifecycle transitions are emitted as events rather than folded into counters, so a restart does not move a number someone alerts on.

Process-local is a deliberate limit, not an oversight. These counters describe one process since it started. The durable record is the delivery ledger, which is designed for that job. They live in controller/service/src/operations.rs.

Shutdown finishes the work it already accepted

A service that exits on a signal drops the delivery it just acknowledged, and an acknowledged webhook is one the provider will not send again. Restarts are ordinary. Losing a verdict per deploy is not.

On a termination signal, in-flight HTTP work finishes. A webhook worker finishes its current delivery and leaves the durable backlog for the next process, which is the right split: the backlog is already in the inbox, while the delivery in hand has state only this process holds. The GitLab lane also finishes admitted evaluations and any running ledger maintenance, since maintenance interrupted halfway is what leaves a root needing recovery.

A second termination signal aborts the process rather than waiting on a stuck drain. That is the escape hatch an operator needs at three in the morning, and it is documented so nobody has to discover it by holding a key down. Drain is controller/service/src/shutdown.rs.

Hostile provider input is tested without provider accounts

Robustness checks that need a live provider account run rarely, run late, and stop running when a token expires. The input worth testing does not need an account: it is bytes arriving at a listener.

Two fuzz targets construct valid signed GitHub and Gitea-family webhooks and valid GitLab OIDC material, then vary exactly one fact: an identity, a binding, a replay marker, or a freshness claim. Starting from a valid request and breaking one thing is what makes the result meaningful. Random bytes mostly test the parser’s first branch, while a correctly signed request with the wrong audience tests the check that matters. Committed seeds keep the corpus, a deterministic smoke lane runs in CI, and a nightly coverage-guided run goes deeper.

The same change removed a smaller problem. Four RSA private keypairs were committed in the tree as test fixtures. They were only test keys, but a valid private key in a repository is a finding in every scanner that looks, and explaining that forever is worse than fixing it. A fixtures crate now generates one pair per test process, which also proves freshness in a test rather than in a comment.

The targets are controller/fuzz/fuzz_targets/, with the keypair generator in controller/fixtures/.

Last change: , commit: 437f65fd

Reference coverage

Closed July 2026. The scan ledger had measured what the engine could not resolve across ten public repositories and sorted it into named classes. Naming a class is not answering it, and a class left named is a caveat a reader has to carry. This phase answered all four, and refused a fifth with the measurement that killed it.

Each answer had the same entry condition, the one the Markdown adapters already met: a pinned grammar, a conformance corpus, extraction goldens, resource accounting, and honest opaque regions. None of them entered on intuition, and the rescans are in the ledger beside the original counts.

A heading anchor belongs to the renderer, so twelve of them are pinned

## Setup & Config has no identity until something renders it, and renderers disagree. github.com publishes setup--config, VitePress publishes setup-config, and Gitea publishes nothing at all if the heading empties out under its filter. The engine used to decline every anchor for that reason, which was honest and also meant 123 of the ledger’s missing rows were invisible.

Twelve rules are pinned, one per renderer or per configuration of one, and the resolver asks whether any of them would publish the anchor. The union is deliberate: adding a rule can only grow what an anchor may match, and no repository policy narrows it. A document can add to the set by declaring an identity the way it would add a heading, in raw HTML, in an attribute block, or in the MDX comment Docusaurus reads, which is an edit a reviewer sees rather than a setting that clears a finding.

Seven of the twelve have a runnable implementation, and against those the table reproduces all 9,049 headings harvested from the ten ledger repositories with no mismatch. The other five are transcribed by hand from their renderer’s source, and the published vectors say which is which and what each transcription is not checked against, because a rule that quietly stops matching its renderer looks exactly like one that still matches.

What the rules are and how far apart they sit is What thirteen renderers call a heading. Published in #135, pinned in #137, resolved in #138, then extended over four more changes to reach raw-HTML headings, declared identities, the MDX comment, and the entity spellings a raw-HTML heading anchors under, ending at #156.

A destination is asked again under the spellings a router serves

A documentation site serves guide and guide.html for a file called guide.md, and a directory’s README.md as its index. An author who writes the served spelling is writing a working link, and the engine was reporting it missing.

A relative destination the tree does not hold is asked once more under those spellings. The first spelling that names a file resolves the reference, and the report names the file that answered while the occurrence keeps the destination the author wrote. The safety property is what makes the union acceptable: a spelling can only reach a file the tree already holds, so it widens what resolves and can never invent a target. A promised directory and a same-repository forge URL are never re-spelled at all.

The spellings were harvested from three routers rather than transcribed from documentation. Across the ten trees they moved 247 of the 516 missing references and moved nothing else. All 241 of starship’s were preset page names across twenty-two translations; mdBook’s six were its own output extension. The union, the routers it came from, and what it costs are in What a documentation router serves.

Split from the anchor class in #141, pinned in #142, answered in #143, measured in #146 and #149.

A generated target is answered from the declaration the repository already publishes

The largest class the ledger measured was targets a documentation build writes and a tree never holds: 102 of ruff’s 104 missing rows, 63 of them into one docs/settings.md. The engine cannot run a docs build, and a configuration file naming generated paths would be a new thing for maintainers to write and for the engine to trust.

So it asks a declaration the repository already publishes for Git. Only the tracked .gitignore files on the path’s own ancestor chain can answer, and a line qualifies only when it is anchored with a leading slash, carries no pattern or escape byte, is neither a comment nor a negation, and spells a path with no empty, ., or .. segment. The nearest file that names the path answers and travels with the result.

The narrowness is the point. The engine never asks whether a path is ignored; it asks whether a tracked ignore file names exactly that path, because one wildcard would let a single line answer for an unbounded number of references. Git applies no ignore rule to a file already tracked, and neither does this, so a path the tree holds never reaches the question.

The outcome is target-declared-untracked, a record under both profiles rather than a cleared finding, so the reference stays counted and the claim travels with it. Measured on the binary from that branch: ruff moves 94 of 104 rows out of explicit-target-missing and keeps every one counted, all declared by docs/.gitignore, and uv moves 54 of 55 from its root .gitignore. Both still exit 1 under enforce, because the leftovers are real.

Read in #171 and asked in #172. The parser is crates/amiss-scan/src/declared.rs.

Two line shapes were added after this phase closed, since a leading slash is not how Git anchors a pattern and a directory that keeps nothing says so with one character. Resolution carries the rule as it now stands.

AsciiDoc and reStructuredText are read by their own parsers

Both had sat as one roadmap candidate for months, and treating either as Markdown with different punctuation would have produced confident wrong answers. Their reference vocabularies are their own: xref:, <<id>>, link:, and include:: on one side, hyperlink targets and four file-naming directives on the other, with roles left declared rather than guessed at because they are an open extension point.

Each is read against the grammar its renderer defines. Docutils’ make_id and Asciidoctor’s Section.generate_id became the eleventh and twelfth anchor rules, so a heading anchor on either document type resolves the same way a Markdown one does.

Two AsciiDoc behaviors needed rules of their own, and Quarkus found both. The first end-to-end run over its 355 documents reported 1,098 missing targets and not one was real. A target still holding a {name} attribute cannot be a path, because the value arrives when the site is built and this engine reads two trees; across Quarkus that is roughly a quarter of every reference. Charging those as unsupported semantics rather than as misses left nine, every one a .adoc file the tree genuinely does not hold. Enabling anchors then surfaced the second: a document that transcludes another publishes a partial anchor set, because include:: splices before Asciidoctor parses and this engine does not splice. Quarkus produced 127 of those, and an anchor absent from a transcluding document is now undecided rather than absent.

The later bounded local graph narrows that boundary for option-free relative includes whose targets are scanned under the same grammar. Attribute-dependent and selected includes remain partial, and expanded AsciiDoc still proves presence rather than absence because the build owns its attribute and conditional state.

Both are described in Resolution. AsciiDoc landed over five changes from #177, which first counted the markup the engine could not read, to #181, which published the identity Asciidoctor gives a section. reStructuredText followed the same three steps in #183, #184, and #185. The crates are crates/amiss-adoc/ and crates/amiss-rst/.

The fifth candidate was measured and refused

Inferring a reference from a bare filename in prose was the obvious next widening, and it is the one this phase declined. Across three trees the strongest available signal is a path-shaped token inside a code span, and 55 to 85 percent of those name nothing in the tree. Requiring a slash lowers the rate rather than raising it.

What the non-resolving pile holds is documentation’s own teaching examples. ruff’s twenty-two most frequent are main.py, a.py, b.py, mypackage/__init__.py and their kind: 564 mentions that were never references and can never be fixed. A tool reporting them would file more than a thousand rows against ruff to surface the ten real missing targets the explicit checker already finds, and it would be worst exactly where documentation is densest, because the pages that teach with examples are the pages full of filenames that do not exist.

The refusal went into What Amiss is not rather than staying on the roadmap, so the next person who proposes it from intuition meets the measurement first. Dropped in #176.

Last change: , commit: ddaf72fb

Live provider evidence

Closed August 2026. A provider lane tested only against local HTTP fixtures answers the way its author expected the provider to answer, so the fixtures are regression tests and not evidence. This phase retained runs against the providers themselves, one positive and one revoked-control pair per lane, with the candidate content held fixed inside each pair so the verdict could flip for exactly one reason. The rows live in Retained provider runs, which stays the live chapter; this page records how the phase closed.

GitHub, Gitea, and Forgejo closed in July 2026 against github.com, a self-hosted Gitea 1.27.0, and a self-hosted Forgejo 16.0.1, controller 2dbb0b6, action tree ca5b2b24f3c3. Drift verdicts from the same instances, where the candidate broke a documented reference and the control stayed intact, are kept in pull request 131.

GitLab closed in August 2026 without waiting for the self-managed release: gitlab.com deploys ahead of it and was running 19.3.0-pre under an Ultimate trial, and the lane’s floor is structural rather than a version compare, so the served merge_train_enforcement field was the whole test. Controller d6d42de, action tree b4b576da at v0.17.0, an enforced merge train on a protected project, and the pipeline execution policy pinned by commit. The enforcement control was revoked to allow_bypass for the second row, the policy job failed on 412 and the train dropped the car, and the restored train then merged the same content.

The campaigns kept paying in the same currency: six defects across the four lanes that every fixture had agreed with. July’s four are recorded with the July rows. August found two more, the documented policy job wrapping its script across YAML lines that policy injection preserves literally, fixed by keeping the command on one physical line in the lane page, and gitlab.com answering the jobs API with a null source for the policy job, fixed by accepting an absent REST source while the signed OIDC job_source claim and the pinned job_config binding continue to state the provenance.

What the phase defends: every supported lane’s trust story now rests on verdicts the provider published and still holds, not on what a fixture author believed. The milestone that was still open when this page closed, the wire leaving experimental, has since completed too: A settled wire records the freeze.

Last change: , commit: 7222a7f3

A settled wire

Closed August 2026. A machine consumer of the report could not build against a contract that reserved the right to reshape under it, and pinning an engine release only moved the problem. This phase gave the wire its own version, decoupled from engine releases, ran a public quiet period under a mechanical tripwire, and froze the contract at 1 when the last condition closed. The report stays the live chapter; this page records how the freeze was earned.

The mechanism shipped first, in v0.18.0. compatibility is the payload’s own version and travels inside every report, so a consumer reads stability from the report rather than from the version of the binary that wrote it. Engine releases were declared to mean engine behavior only. The clock got a tripwire in the tree: the example the last release shipped is retained beside the rolling one, refreshed by the release workflow, and a contract test fails the build the moment it stops clearing the current schema and reader, which is exactly a reshape.

Three conditions had to hold at once, and each has its own exit evidence. Every supported lane’s trust story closed on retained live evidence, recorded in Live provider evidence. The release carried no half-built trust path: the launcher placeholder was cut, and the verified-consumption lane is the attestation recipe in Security model, checked against every release the workflow publishes. And two consecutive minor series shipped without reshaping the payload: v0.19.0 and v0.20.0 carried the fix, claim, and adopt verbs, two report projections, and a policy grammar binding, all additive, with the tripwire green through both.

The freeze itself is additive law made mechanical. The writers emit 1 from one wire constant, the schema pins the same value by a contract test, and the first frozen example is retained permanently at spec/examples/scanner-report.frozen-1.json, byte-pinned against edits, with every later schema in the major required to keep validating it. A 1 report may gain optional fields as 1 rolls forward; nothing a 1.0 consumer parsed ever changes meaning or disappears. Reshaping past that promise mints 2, and since a new contract breaks consumers whatever the binary is called, that release is a major one. The engine’s own 1.0 remains a maturity statement about the engine, made on its own grounds; the wire did not wait for it and does not follow it.

Minted 2 on 15 September 2026, the first reshape since the freeze. Every observation row had carried its candidate side as a full copy of the base occurrence even when the two were equal, and every occurrence had carried its identity input twice, once as observation_id_input and once as the top-level adapter_id, document, intent, source_construct and source_projection_digest. On this repository’s own scan that was 2.4 MB of a 3.0 MB report. Under 2 an occurrence is its identity input plus its outcome, so the five copies are gone and a reader takes them from the input, which still ships whole and still recomputes the observation id; and a comparison carries its sides once: same holds the one occurrence both trees hold, and each holds a base and a candidate, either of them null. Observation ids, finding keys and finding facts did not move. The frozen example that opens the major is spec/examples/scanner-report.frozen-2.json, byte-pinned like its predecessor, and frozen-1 stays in the tree as the record of major 1. The example the last release shipped keeps carrying 1 until the release workflow refreshes it, and the contract test allows exactly that window, as it allowed the founding one. Every reader of 2 refuses a 1 report. 2 is additive within its major as 1 was, and the next reshape mints 3. That reshape came later the same month, to let a reason grow inside a major; its record is in A reason may grow.

Last change: , commit: 6e8029dc

A reason may grow

Closed September 2026. The wire promised that a report stays additive within a major, and a closed enumeration broke that promise the moment it gained a value. A report carrying a reason that did not exist in 0.29.1 was read by the released 0.29.1 binary as amiss render: the input is not a scanner report envelope, exit 2, and the same report without that value read fine. Three such values were already waiting in branches, and more will arrive as documentation generators get modelled, so minting a major for each one was never going to work. The report stays the live chapter; this page records what 3 changed and where the line fell.

The line runs between a reason and a verdict. A reason says why an answer could not be given, and the answer sits beside it: a document row’s unsupported_reason next to its status, an unsupported-semantics row’s reason next to its kind. A consumer that meets an unfamiliar reason still knows the document went unscanned, or the reference went unevaluated, so it loses nothing by keeping a spelling it cannot name. A kind, a disposition, a status, a schema name and compatibility itself are the opposite. A consumer that meets an unfamiliar one of those cannot judge the verdict at all, so they stay closed and a reader still refuses them.

Two enumerations crossed. The document row’s unsupported reason and the unsupported-semantics reason are open strings under 3. Both stay derive-only: strum carries the closed spellings, and #[strum(default, transparent)] on a variant holding a String gives the tolerant FromStr and the Display that writes it back, with nothing written by hand. An unfamiliar reason therefore round-trips byte for byte, which is not a nicety. The payload digest covers the reason, so a report the reader could not re-spell exactly would fail its own seal. A contract test reads a report carrying an invented reason, writes it back, and compares the bytes.

Making the semantics reason grow cost one shape. Under 2 each reason selected its own body, so query and code-fragment carried a target, fragment carried a blob target, and the four route reasons carried none. Under 3 the row is one shape for every reason, the reason and the target it located when it located one, and a new reason needs no new row. The engine still writes a target for query, code-fragment and fragment and for nothing else, but the reader no longer refuses a target on a reason that would not carry one. The missing reasons did not move, since each of those selects which evidence its row carries and each feeds the finding key.

The mint itself followed 2. The writers emit 3 from one wire constant, the schema pins the same value by a contract test, and the example that opens the major is retained permanently at spec/examples/scanner-report.frozen-3.json, byte-pinned like its two predecessors, with every later schema in the major required to keep validating it. frozen-2 stays in the tree as the record of major 2, on the terms frozen-1 already had. The example the last release shipped keeps carrying 2 until the release workflow refreshes it, and the contract test allows exactly that window. Every reader of 3 refuses a 2 report: the render and refs verbs, the external plan, and the controller. 3 is additive within its major as 2 was, and a reason can now grow without minting 4.

Last change: , commit: 6e8029dc

Projection contracts

Closed August 2026. The scanner could establish that a reference reached a repository object, but it could not hold visible documentation equal to the exact source or producer-owned value it claimed to show. This phase added one policy-owned relation instead of a family of code-, table-, and language-specific checkers. The live contract is in Controls and policy and Trusted semantic evidence; this page records why the relation is narrow.

The first step closed the completed-site fragment rules against raw and percent-decoded anchors, legacy names, redirects, invalid escapes, and duplicate targets in #570. A projection then received one stable (document, name) identity, one typed source, one adjacent visible sink, and one projection kind in #571. The scanner evaluates that closed relation in #576, while #577 makes removing it a policy weakening rather than letting deletion erase the obligation.

Exact source selection, not a formatter language

A source is already meaningful before it reaches a sink. Exact blob lines and the named regions from #572 select repository bytes. The shared typed projection primitives from #573 let a complete tree-path selection become sorted rows in #579 or a canonical decimal count in #574. Selection reuses the snapshot discovery the scan already paid for; named regions use one exact ordered marker pair and never run a regex or repository process.

Comparison normalizes line endings and removes exactly one terminal newline, but preserves every other byte. Sorted rows retain duplicate multiplicity and ordering defects. The two-pointer difference in #580 reports exact totals and bounded missing and extra previews instead of copying an unbounded mismatch into the report. The grammar is closed: there is no template language, arbitrary table schema, or source parser hidden in a sink.

Completeness controls what absence can prove

The same evaluator consumes producer-owned records. A present row can project one exact value after #583, but an absent row means absent only when the producer declared that exact set complete. Complete sets can project all display values or their count after #584; partial sets cannot prove equality, extra rows, counts, or absence. Keys remain identity even when two rows display the same value.

Projection work has its own ceilings rather than borrowing the scanner’s aggregate memory bound. #582 meters assertion count, selected and projected bytes, compared records, and copied previews independently. The retained measurements cover equal, first-different, last-different, all-different, large-row, and shared-source inputs; the limits came from integrated release runs rather than an estimate.

Local authoring does not create authority

The public scanner can bind one candidate-free semantic template to the exact invocation candidate after #585. The offline authoring command added by #586 bounds and canonicalizes specialist key/value rows into that template. Neither path executes a producer, authenticates its claims, or changes the report from self-asserted; provider authority still requires an independently planned acquisition.

Two tempting extensions deliberately did not close. A fixed two-column table has no retained user, so code blocks and exact row projections remain the visible forms. Automatic projection rewrites have no retained real findings that prove stable replacement identities; until they do, Amiss reports the exact drift and does not rewrite the sink.

Last change: , commit: a3e8a428

Authoritative semantic artifacts

Closed August 2026. External producers can know facts the repository tree cannot, but trusting only their normalized answer would make a later report impossible to audit. This phase bound the operator’s expectation, the provider run, the exact acquired bytes, the candidate-bound envelope, and the retained report into one replayable chain. It also proved that a language specialist can enter that chain without putting a language parser in the engine or provider services. The live contract is Trusted semantic evidence.

The input survives the producer artifact

#587 records the exact candidate-independent template and the canonical candidate-bound envelope, including their acquisition and digest identities. #588 retains those bytes with the accepted report so restart and retry never reacquire mutable output. #589 adds authenticated retrieval and a provider-visible locator, allowing the audit lifetime to outlive the workflow artifact that supplied it.

The repository does not choose what workflow is trusted. The immutable expectation from #590 fixes provider, repository, workflow, event, artifact and sole payload names, producer context, and archive and file limits. GitHub archive decoding in #591 refuses traversal, links, duplicate or extra members, and decompression growth. Exact run selection and bounded signed download arrive in #592, service wiring in #593, and authenticated completion scheduling in #594. One scanner lease never polls an entire build.

The first typed specialist stays outside the engine

The unpublished Rust specialist reads bounded, format-matched Rustdoc JSON and emits one complete record-set@1; it invokes neither Cargo nor Rustdoc. #595 normalizes root-crate public free functions, and #596 adds inherent and trait declarations with disjoint stable keys. Public aliases come from the maintained Rustdoc adapter. Numeric Rustdoc IDs never become identities, and ambiguous specialized inherent functions are refused rather than disambiguated by parsing rendered Rust syntax.

Real format-matched Rustdoc, Cargo feature boundaries, and host versus wasm32-unknown-unknown target boundaries are pinned by #597, #598, and #599. A configuration is complete only for its exact compiler, format, package, target, triple, features, cfg, and dependency digest. The generic record-value and record-set projection contract attaches those declarations to visible docs in #600; no Rust-specific scanner finding or evaluator was added.

Unsupported producers remain explicit

Only GitHub has a planned workflow-artifact acquisition path. Gitea/Forgejo and GitLab remain held until an operator supplies a real artifact API, credential model, and workflow contract. A second completed-site producer remains held for the same reason, so no speculative Sphinx/MkDocs assembly was extracted from the proven mdBook path.

The Rust specialist deliberately stops at functions. The maintained dependencies do not yet expose the larger public item surface as structured stable kind, path, and declaration rows from already bounded bytes; Amiss will not parse another crate’s rendered text or carry a fork to pretend that gap is closed. Executable-example receipts and generated-diagram projections also remain demand gated. Neither summary JUnit nor exact image bytes would by itself prove the semantic claim those features are often assumed to make.

Last change: , commit: a3e8a428

Offline audit sidecars

Closed August 2026. A repository report cannot prove what was later deployed, which product a site describes, or whether two locale inventories cover the same pages. Putting those facts into the scanner report would give one candidate a claim over events outside its trust boundary. This phase instead added closed report-bound sidecars with the same conservative matched, refuted, and unproven vocabulary. Publication audits and Locale coverage audits own the live contracts.

Publication is an exact relation, not a URL guess

The publication plan in #601 binds the accepted report, docs candidate, completed-site artifact, deployment target, exact product resource, producer, and operator relation rule. Provider-normalized evidence from #602 repeats those identities beside one immutable successful deployment record and workflow definition. URLs, tags, timestamps, and similarly named channels never substitute for resource digests.

#603 assesses the pair offline through one Result flow. Failure, missing evidence, mutable identity, or any mismatched binding cannot become a match. The controller independently validates that chain against its accepted scanner report in #604, then #605 retains and reopens the exact report, plan, optional evidence, assessment, and digest set. Retry replays bytes; it does not rejudge a changed deployment.

Locale coverage separates presence from provenance

#606 first extracted the shared bounded, digest-bound sidecar envelope before adding another audit family. The locale plan from #607 then fixes opaque stable page keys, source and target locales, coverage policy, producer context, and an optional exact product resource. Independent complete or partial inventories arrive in #608, and the bounded assessment in #609 reports missing and orphan keys only where completeness permits it. A present key proves coverage, not translation quality.

Fallback cannot masquerade as target-owned content after #610: every fallback has an operator-authorized opaque class and exact source digest. #611 compares explicit producer-owned source lineage and refuses to infer it from time, text similarity, or Git adjacency. #612 reuses publication’s exact product resource on both inventories, so a locale version label cannot impersonate a release identity.

The offline boundary is part of the result

No controller lane acquires locale evidence; the command that stages it from a checkout, amiss locale-inventory, came after this phase. Publication has controller validation and durable retention, but no provider lane authenticates deployment completion or publishes a post-deployment audit. GitHub, GitLab, and Gitea-family deployment integration remains operator gated because their environment, artifact, credential, completion, and public-destination contracts are not interchangeable. The milestone is the reusable offline proof and retention core, not a claim that a live deployment can already invoke it.

Last change: , commit: fb49254d

Cross-repository relation core

Closed September 2026. Documentation and code can live in different repositories, but neither repository is allowed to choose the other repository, its credential, the comparison, or where a verdict is published. This phase built the provider-neutral relation lifecycle beneath that rule: four exact snapshots, one symmetric projection transition, durable supersession, and restart-safe status delivery. Cross-repository relations is the live contract; this page records what the core proves and where live assembly still stops.

One operator plan owns four exact snapshots

The immutable two-subject registry in #613 fixes both provider scopes, repositories, branches, credentials, selectors, budgets, trigger ownership, and status destinations atomically. #614 acquires each base/candidate pair into a physically independent Git root under per-subject and aggregate streaming limits; an unavailable or unverifiable subject leaves the complete relation unproven.

The portable audit is split into a plan, four-slot projection evidence, and a replayable equality transition by #615, #616, and #617. It can say aligned, introduced drift, pre-existing drift, resolved drift, or unproven without appointing either repository as truth. The pure tree projector and controller projection path land through #618 and #619, with operator-context binding in #620. Accepted-report decoding is shared in #621, the complete chain is independently replayed in #622, and component derivation plus immutable retention close in #623 and #624.

Coordination is opaque and supersession is fenced

The operator-supplied coordination identity added in #625 may mean a pair, release, or workflow occurrence; Amiss never infers it from timestamps or nearby heads. The pure admission law in #626 preserves an exact retry, rejects identity rebinding, and advances a fence when new coordination supersedes pending work. #627 persists the same law in a bounded hash-chained journal whose committed head makes interrupted appends recoverable and committed mutation visible.

Fresh two-subject heads freeze only configured destinations in #628, and #629 reserves every external status key to one relation. Pure fenced staging arrives in #630; a tagged journal action grammar in #631 lets the durable outbox land in #632. Restart reopening, per-destination durable acknowledgements, and serialized oldest-fence claims follow in #633, #634, and #635. Dropping a claim mutates nothing; unrelated lock shards can still publish in parallel.

Provider boundaries preserve provider semantics

GitHub exact head resolution and idempotent App-owned check runs land in #636 and #637. Gitea-family commit statuses arrive in #638 with their lack of writer-bound merge-gate identity stated rather than hidden. GitLab’s #639 keeps its result synchronous and bound to the authenticated active policy job instead of pretending it has the same asynchronous status model. Gitea-family exact head resolution follows in #640.

Service assembly is likewise provider neutral: strict registry loading in #641, exact credential routing in #642, and authenticated coordination admission in #643. Exact provider heads are retained and frozen in #644 and #645; canonical audit construction and the projection, assessment, retention, and staging pipeline land in #646 and #647. The provider-neutral loop resumes durable claims and acknowledges only reconciled destinations after restart in #648.

The provider binaries still do not construct and install this registry, credential router, snapshot acquisition, and lifecycle as one live lane. That topology needs an operator contract rather than a hidden cross-provider default. Post-publication wiki drift also remains demand gated and would be a separate Git subject observed after publication, never a pre-merge guarantee. Privacy-safe external URL histories, organization-scale routing, and portable signed outcomes remain research until real operators supply their retention, isolation, and trust-root requirements.

Last change: , commit: a3e8a428