# Amiss, the whole book --- # Introduction Amiss checks structural relationships between documentation and the repository tree it describes. It discovers a closed set of Markdown and MDX documents, extracts supported explicit references, and resolves their repository targets. When a supported reference points at a file or line range that is gone, it reports that. When the selected target bytes changed while the paragraph containing the reference did not, it reports that too. It never reads meaning: it cannot tell you whether a sentence is true, and it does not try. ```dot process digraph introduction { rankdir = LR; node [shape = box, fontname = "Latin Modern, Georgia, serif", fontsize = 11]; edge [arrowsize = 0.7, fontname = "Latin Modern, Georgia, serif", fontsize = 10]; states [label = "base and\ncandidate commits"]; discover [label = "discover\ndocuments"]; extract [label = "extract supported\nreferences"]; resolve [label = "resolve\ntargets"]; compare [label = "compare targets\nand paragraphs"]; report [label = "report findings\nand visibility"]; states -> discover -> extract -> resolve -> compare -> report; } ``` ## The supported boundary "Supported explicit reference" is an important boundary. Bare path-like prose is not inferred. Raw HTML and MDX code regions are opaque. Site routes, code symbols, live URLs, and other repositories need information this engine does not have, so they stay visible as declared boundaries instead of being guessed at. Line fragments, heading anchors and router spellings are the exceptions: a line fragment selects bytes, a heading anchor is answered against pinned renderer rules, and a destination the tree does not hold is asked again under the spellings a pinned router serves, which can only reach a file the tree already holds. [Discovery](discovery.md) and [Resolution](resolution.md) describe the boundary rows, and [Project status](status.md) links the classifier and resolver that draw them. ## The four questions A run compares two exact states of the repository: the base (usually where your branch started) and the candidate (usually the commit being reviewed). Amiss answers four questions about them, and nothing else: 1. Does every 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 cannot handle is worse than no checker, because its green result claims more than it checked. So everything Amiss cannot read or follow becomes a visible row in the report, and a document it cannot decode at all fails the run rather than dropping out of it. ## What a run never does The scanner keeps no state. There is no baseline file, no cache, no database, and nothing committed to your repository. Run it twice on the same commits with the same engine binary and you get byte-identical reports. Repository policy may expand discovery and raise three structural finding kinds, but it cannot downgrade a disposition or suppress a finding. How the project arrived at that stance is told in [Provenance](provenance.md), and the exact policy boundary is in [Controls and policy](controls.md). Each promise below is enforced by a test in the suite: - It never writes to your repository. The [no-write suite](https://github.com/HardMax71/amiss/blob/main/crates/amiss/tests/no_write.rs) compares trees before and after commands and also scans a read-only repository. - It never runs repository code and never calls the `git` command. It reads [Git](https://git-scm.com)'s objects, packs, and index through the [repository reader](https://github.com/HardMax71/amiss/blob/main/crates/amiss-git/src/repo.rs). - It never follows symlinks while reading. A link placed at the repository root, at `.git`, or anywhere along an object's path is refused, and the refusal is never confused with a missing file. - It never touches the network. The engine has no acquisition or network interface; missing objects are refusals rather than fetch requests. - The same repository, commits, and engine binary give the same report bytes, run after run. - Stable public resource ceilings have names and published values. A measured crossing produces a typed error naming the limit and observed lower bound. Parser CPU work that occurs before node and depth accounting is a disclosed limitation in [Security model](security.md), 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. Start with [Invocation](invocation.md) if you just want to run it. ## Licensing Amiss source code and documentation are distributed under the [Functional Source License 1.1, ALv2 Future License](https://github.com/HardMax71/amiss/blob/main/LICENSE.md). The repository's [third-party notices](https://github.com/HardMax71/amiss/blob/main/THIRD_PARTY_NOTICES.md) attribute the parser evidence, documentation assets, and fonts. Released Action trees include the project license and a plain-text license bundle built from the locked dependency graph. The Latin Modern webfonts served by this book are covered by the [GUST Font License](fonts/GUST-FONT-LICENSE.txt), and notices embedded by mdBook in generated JavaScript and SVG assets are retained in the published site. --- # 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/`: ```diff --- 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: ```markdown Retries are capped at three attempts; the limit lives in [`src/retry.rs`](../src/retry.rs). ``` The paragraph did not change, so nothing in the review draws an eye to it. Against these two snapshots Amiss reports the link's target as gone from the candidate tree, which blocks under `enforce`. Had the constant changed in place instead, the changed bytes under the unchanged paragraph would warn. What neither finding claims is that "three" is now a lie; that judgment belongs to whoever reads the finding. The [audit behind this tool](evidence.md) 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 freshness of the output proved only that the stale input still compiled. Executable examples all stayed green, because 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 detects and gates, and leaves repair 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, a boundary [Correlation and impact](correlation.md) draws precisely, because the code moving is a reason to reread the prose and no 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](profiles.md); what Amiss deliberately does not attempt, starting with reading your prose, is in [What Amiss is not](non-goals.md). --- # Invocation Install from [crates.io](https://crates.io), or build from source: ```sh cargo install amiss ``` Every release also carries the engine prebuilt for Linux x86_64, both macOS architectures, and Windows x86_64, with a `SHA256SUMS` file and the sigstore bundle that attests it. `gh attestation verify --repo HardMax71/amiss` matches a downloaded binary against the build that produced it. The command line is closed: the grammar below is everything, each option appears at most once, order does not matter, and anything else is rejected as an invalid invocation. There is no `--help`; a rejected human invocation prints this same grammar under its refusal, so the binary teaches the command even where this book is not installed, and the copy below is checked against the binary's in CI. ```text amiss check --repo --object-format --base (--candidate | --index) [--repository // --ref refs/heads/ --default-branch-ref refs/heads/ [--forge ]] --profile [--explain-scope] [--format ] amiss --version ``` The table gives each flag in one line; the paragraphs after it carry the exact semantics and are the ones to trust when the short form reads ambiguous. | Flag | Value | Role | | --- | --- | --- | | `--repo` | path | the repository checkout to read | | `--object-format` | `sha1` or `sha256` | the repository's object format | | `--base` | full commit ID | the state the comparison starts from | | `--candidate` | full commit ID | the state under review; exclusive with `--index` | | `--index` | none | checks the staged state against the base instead | | `--repository` | `//`, lowercase | unverified identity claim for same-repository URLs | | `--ref` | `refs/heads/` | the candidate branch this tree belongs to | | `--default-branch-ref` | `refs/heads/` | which branch counts as default when resolving URLs | | `--forge` | `github`, `gitlab`, or `gitea` | URL dialect; an explicit flag beats the host table | | `--profile` | `observe` or `enforce` | report only, or let blocking findings gate; see [Profiles and findings](profiles.md) | | `--explain-scope` | none | adds deterministic scope lines to human output | | `--format` | `human` or `json` | ten grouped items, or the exact report in [The report](report.md) | | `--version` | none | prints this binary's version and engine digest; stands alone, with no `check` and no other flag | `--base` and `--candidate` take full commit IDs, never branch names or short forms: Amiss evaluates exactly the trees you name and resolves nothing for you, and `--index` checks the staged state instead, including entries marked [skip-worktree](https://git-scm.com/docs/git-update-index). The identity group is a claim Amiss cannot verify, so its spelling is strict: the host is matched byte for byte against the URLs in your documents, owner and name must be lowercase with segments nested only for GitLab group paths, a workflow passing `github.repository` has to lowercase it first, and a wrong spelling is refused rather than rewritten. `--ref` names the candidate branch for URL resolution only; it is not a protected target branch, there is no `--target-ref`, the report's target stays null, and no spelling of these fields turns a CLI run into a provider-authenticated one. A URL for the declared default branch while another candidate is under test is recognized but reported as `unsupported-version-scope`, and without the identity group forge links stay external URLs and the report says so. `--forge` names the dialect the resolver applies: `github` covers GitHub and GitHub Enterprise, `gitlab` the separator form, `gitea` also Forgejo and Codeberg. Without the flag, github.com, gitlab.com, and codeberg.org select their own dialects and every other host selects none, leaving that host's links foreign and `evaluation.forge` null. An explicit flag always beats the table, which is how a self-hosted instance gets its grammar; the github and gitea dialects refuse a nested owner they could never match, and recognizing a dialect authenticates nothing about how the run was invoked. `--format json` emits the exact report described in [The report](report.md); `human` prints the same facts as at most ten grouped Fix and Check items, each naming only its target and affected-place count, with one fixed `note` line per error code using the sentences from [Limits and refusals](limits.md), while the full findings remain in JSON. `--explain-scope` adds its lines to that human output without changing JSON or creating an early exit, behavior pinned by the [CLI test](https://github.com/HardMax71/amiss/blob/main/crates/amiss/tests/cli.rs). 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 --version` is the second form of the grammar and the only one that is not a scan. It carries no other flag, prints two lines to stdout, and exits 0: ```text amiss engine sha256:<64 hex digits> ``` The first line names the binary and the version it was built from. The second is the same `engine_digest` that this binary stamps into every report it writes and that the [release manifest](https://github.com/HardMax71/amiss/blob/main/spec/scanner-release-manifest.schema.json) pins for each published platform, so an installed binary can be matched against a release row, and a report can be matched back to the binary that produced it, without running a scan. A binary that cannot read its own file prints `engine unavailable` on the second line and still reports its version. The other shipped binaries answer `--version` too, each with one line naming itself: `amiss-bootstrap`, `amiss-manifest`, `amiss-constraint`, and the three provider services. --- # Profiles and findings A finding is one fact the scan established: one link, one file, one document, with four parts. The kind says what happened. The attribution says whose change it is: `introduced` by this candidate, `pre-existing` before it, `resolved` by it, `not-applicable` when the before-and-after framing does not apply, or `unknown` when the match-up could not be decided without guessing. The disposition says what the run does about it: `record` (noted), `warn` (shown), or `fail` (blocks). The location says where, down to byte offsets. The profile picks the built-in disposition for each kind. `observe` turns the three structural reference failures into warnings, while `enforce` makes them blocking. Several control-integrity findings fail under both profiles, and many coverage or change observations are records rather than warnings. The exact table below is generated from [`FindingKind::built_in_disposition`](https://github.com/HardMax71/amiss/blob/main/crates/amiss-wire/src/report.rs) and checked in CI. | Finding kind | Observe | Enforce | | --- | --- | --- | | `explicit-target-missing` | `warn` | `fail` | | `explicit-target-type-mismatch` | `warn` | `fail` | | `invalid-reference` | `warn` | `fail` | | `target-declared-untracked` | `record` | `record` | | `unsupported-reference-semantics` | `record` | `record` | | `unsupported-document-format` | `record` | `record` | | `unsupported-target-kind` | `record` | `record` | | `unsupported-version-scope` | `record` | `record` | | `unsupported-capability` | `fail` | `fail` | | `dependency-changed-subject-unchanged` | `warn` | `warn` | | `dependency-and-subject-cochanged` | `record` | `record` | | `subject-changed` | `record` | `record` | | `explicit-reference-removed` | `record` | `record` | | `document-removed` | `record` | `record` | | `opaque-mdx-region` | `record` | `record` | | `opaque-html-region` | `record` | `record` | | `observation-correlation-ambiguous` | `record` | `record` | | `unlinked-document` | `record` | `record` | | `policy-weakened` | `fail` | `fail` | | `coverage-reduced` | `fail` | `fail` | | `control-plane-changed` | `fail` | `fail` | | `debt-worsened` | `fail` | `fail` | | `debt-expired` | `fail` | `fail` | | `waiver-invalid` | `fail` | `fail` | ## What each kind means One fixed sentence per kind, generated from [`FindingKind::meaning`](https://github.com/HardMax71/amiss/blob/main/crates/amiss-wire/src/report.rs) and checked 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 repository target: it escapes the repository or carries a backslash, an encoded separator, or control bytes; fix the destination - `target-declared-untracked`: a reference names a path a tracked ignore file names literally, so the repository declares it does not keep that target and 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, or a fragment on a target it cannot parse; the unchecked part is declared instead of guessed - `unsupported-document-format`: a policy-included document has no parser in this engine; it is discovered and counted, and its content is never scanned - `unsupported-target-kind`: the reference resolves to a symlink or submodule, which Amiss does not follow; the boundary is declared instead of crossed - `unsupported-version-scope`: a forge URL names this repository at another version, a different branch, tag, or commit; only the candidate version is read, so the link is recognized and left unresolved - `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 - `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 document from which zero references were extracted; despite the name, it claims nothing about inbound links from other pages - `policy-weakened`: the candidate loosens its own repository policy, dropping an include, a protected path, 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 ## Before and after Only the shown state changes. Floor, debt, waiver, and trusted-time examples use the control API described in [Controls and policy](controls.md). | Finding kind | Before | After | | --- | --- | --- | | `explicit-target-missing` | `docs/index.md`: `# Index`; `docs/missing.md` is absent. | Append `[missing](missing.md)` to `docs/index.md`; the target remains absent. | | `explicit-target-type-mismatch` | `docs/index.md`: `# Index`; `docs/guide.md` is a regular file. | Append `[guide](guide.md/)`; the trailing slash promises a directory. | | `invalid-reference` | `docs/index.md`: `# Index`. | Append a link whose destination is `../../etc/passwd`, which escapes the repository from `docs/`. | | `target-declared-untracked` | `docs/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-semantics` | `docs/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-format` | Policy includes `docs/spec.rst` as a document; the file is absent. | Add `docs/spec.rst` containing `Title` and an `=====` underline. | | `unsupported-target-kind` | `alias` is a Git symlink; `docs/index.md` has no link to it. | Append `[alias](../alias)`; Amiss will not follow the symlink. | | `unsupported-version-scope` | Run 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-capability` | `docs/claims.md`: `# Claims`. | Append `[amiss:foo]: `. | | `dependency-changed-subject-unchanged` | `docs/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-cochanged` | `docs/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-changed` | `docs/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. | | `explicit-reference-removed` | `docs/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-removed` | `docs/obsolete.md` contains `# Obsolete`. | Delete `docs/obsolete.md`. | | `opaque-mdx-region` | `page.mdx`: `[Parser](src/parser.rs)`. | Append `{"hidden"}`. | | `opaque-html-region` | `page.md`: `[Parser](src/parser.rs)`. | Append a separate `
hidden
` block. | | `observation-correlation-ambiguous` | `docs/guide.md`: `Old [parser](../src/parser.rs).` | Replace it with two paragraphs: `First [parser](../src/parser.rs).` and `Second [parser](../src/parser.rs).` | | `unlinked-document` | `README.md` is absent. | Add `README.md` containing only `# Title` and prose with no references. | | `policy-weakened` | Repository policy sets `explicit-target-missing` to `fail`. | Remove that `finding_dispositions` entry. | | `coverage-reduced` | Repository policy protects `docs/required.md`, which contains `# Required`. | Keep the inventory obligation and delete `docs/required.md`. | | `control-plane-changed` | A 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-worsened` | Verified debt accepts one occurrence of `see [gone](missing.md)`. | Keep the debt item and duplicate that occurrence, changing the finding fact. | | `debt-expired` | Debt 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-invalid` | Waiver 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`. | The control families exist so that loosening rules and presenting invalid outside authority are themselves visible. Repository policy may raise only `explicit-target-missing`, `explicit-target-type-mismatch`, and `invalid-reference`, as the [policy parser and evaluator](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/policy.rs) enforces. It may never lower a disposition. There is no suppression syntax anywhere. The way to remove a repository-policy finding is to fix what it points at. --- # 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: ```yaml - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 2 - uses: HardMax71/amiss@v0 with: profile: observe ``` The published first run uses `observe`: introduced problems appear as Fixes without blocking, changed targets appear as summary-only Checks, and pre-existing problems remain Existing inventory. An incomplete or untrusted run still fails. Triage the initial report, adopt any repository policy it needs, then switch the input to `profile: enforce`. ## 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. Under the default `enforce` profile the job fails on exit classes 1 and 2. The outputs `exit-class` and `report` expose the verdict class and the JSON report path for anything downstream. | Input | Default | Role | | --- | --- | --- | | `profile` | `enforce` | `observe` reports without blocking | | `base` | derived | full commit ID, overrides the event derivation | | `candidate` | derived | full commit ID, overrides the event derivation | | `repo` | `.` | repository root inside the workspace | | `object-format` | `sha1` | or `sha256` | | `annotations` | `true` | displayed Fixes and scan errors become file annotations | | `watchdog-seconds` | `120` | wall-clock window before the scan is ended | When `base` and `candidate` stay empty, the event supplies them: | Event | Base | Candidate | | --- | --- | --- | | `pull_request` | the candidate's own first parent | the merge result | | `merge_group` | the group's base commit | the group's head | | `push` | the event's `before` | the 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 and is present in any checkout that holds the candidate at all. Both commits must exist in the checkout: `fetch-depth: 2` covers the normal merge checkout, and a batched push or unusual checkout may need `fetch-depth: 0`. 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-bootstrap/src/build.rs) that stores an open build-source identity instead of assuming `github.com`; the [release workflow](https://github.com/HardMax71/amiss/blob/main/.github/workflows/release.yml) 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. ```dot process digraph pins { rankdir = LR; node [shape = box, fontname = "Latin Modern, Georgia, serif", fontsize = 11]; edge [arrowsize = 0.7, fontname = "Latin Modern, Georgia, serif", fontsize = 10]; major [label = "v0\nmoving major ref"]; source [label = "vX.Y.Z\nimmutable source tag"]; runtime [label = "action/vX.Y.Z\nimmutable runtime tree"]; major -> source [label = "follows the\nlatest release"]; source -> runtime [label = "dispatcher\ndelegates"]; } ``` ## 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](https://github.com/HardMax71/amiss/blob/main/.github/workflows/ci.yml) 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: ```yaml - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: fetch-depth: 2 persist-credentials: false - run: cargo install --locked --registry crates-io --version '=' amiss - env: REPOSITORY: ${{ github.repository }} BRANCH: ${{ github.head_ref || github.ref_name }} DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} run: | amiss check --repo . --object-format sha1 \ --base "$(git rev-parse HEAD~1)" \ --candidate "$(git rev-parse HEAD)" \ --repository "github.com/${REPOSITORY,,}" \ --ref "refs/heads/${BRANCH}" \ --default-branch-ref "refs/heads/${DEFAULT_BRANCH}" \ --profile observe --format json > amiss-report.json ``` Replace `` 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. 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. ## 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, with one overflow line; only a displayed Fix with a candidate text location becomes a file annotation, while Checks and Existing inventory stay in the summary and report. 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 without rerunning anything. One line lists every grouped PR item with its target and affected-place count: ```sh jq -r '.payload.feedback | select(.status == "available") | .items[] | [.action, .effective_disposition, ((.target | strings) // "-"), .location_count] | @tsv' amiss-report.json ``` ## 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](provider-controls.md) compares those lanes and links their setup, and [Controller delivery](controller.md) documents the shared retry record; the GitHub lane's own page is [GitHub provider lane](provider-github.md). ## Before a commit exists The same check runs on the staged index. The repository publishes a [pre-commit](https://pre-commit.com) hook that scans the staged state against `HEAD` with an installed `amiss` binary: ```yaml repos: - repo: https://github.com/HardMax71/amiss rev: v0.5.1 hooks: - id: amiss ``` --- # 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 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. If the repository keeps an `AGENTS.md`, paste this section into it: ```markdown ## Documentation checks This repository gates documentation drift with Amiss (https://hardmax71.github.io/amiss/). After changing documentation, or code that documentation points at, check the staged state before committing: amiss check --repo . --object-format sha1 \ --base "$(git rev-parse HEAD)" --index --profile enforce --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 `key_input.scope.normalized_target_intent.path` names the target. Exit 2 means the run itself could not be trusted, and the error rows say why. Fix what the row points at; never weaken `.amiss/scanner-policy.json` to silence a finding. ``` The block assumes the binary is installed (`cargo install --locked amiss`); 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`](https://github.com/HardMax71/amiss/blob/main/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. ## Claude Code This repository doubles as a Claude Code plugin marketplace. One command registers it: ```text /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`](https://github.com/HardMax71/amiss/blob/main/integrations/claude/skills/amiss/SKILL.md). --- # 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](limits.md). ## 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](https://github.com/HardMax71/amiss/blob/main/spec/examples/index-projection.json), hash a [synthetic snapshot](https://github.com/HardMax71/amiss/blob/main/spec/examples/synthetic-snapshot.json) over that projection, then bind the result into the staged [candidate identity](https://github.com/HardMax71/amiss/blob/main/spec/examples/candidate-identity-index.json). A commit-pair run uses the corresponding [commit candidate identity](https://github.com/HardMax71/amiss/blob/main/spec/examples/candidate-identity.json). ```dot process digraph identity { rankdir = LR; node [shape = box, fontname = "Latin Modern, Georgia, serif", fontsize = 11]; edge [arrowsize = 0.7, fontname = "Latin Modern, Georgia, serif", fontsize = 10]; projection [label = "sorted index\nprojection"]; synthetic [label = "synthetic\nsnapshot"]; identity [label = "candidate identity\n+ repository, dialect,\nrefs, base"]; projection -> synthetic -> 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/tests/identity.rs) 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](provider-controls.md) 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](https://git-scm.com)'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. The supported repository form is a primary non-bare checkout with a real `.git` directory. A bare repository or linked worktree whose `.git` entry is a file is refused as unavailable, and objects available only through Git alternates are not consulted. These are explicit boundaries of the direct [repository reader](https://github.com/HardMax71/amiss/blob/main/crates/amiss-git/src/repo.rs), 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: ```json { "code": "GIT_OBJECT_MISSING", "phase": "git" } ``` The row also carries `path`, `path_bytes_hex`, `resource`, `configured_limit`, and `observed_lower_bound` fields, null wherever they do not apply, so every refusal has the same shape and a consumer never parses two formats. When the refused thing is a name the path grammar rejects, `path_bytes_hex` holds its exact bytes as lowercase hex, so the report never swallows what it refused. Neither snapshot is trusted more than the other. A base commit missing from a shallow clone is a refusal, not an empty tree, because treating an absent base as empty would make every document look newly added and flood the report with false `introduced` findings. Comparing two trees only means something when both trees are exactly the ones you asked for. --- # Discovery Discovery decides which files count as documents, and it is deliberately narrow. 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. Every other file is a possible reference target, not a built-in document. These rows come directly from the [classifier](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/document.rs). Nine directory names are always skipped, wherever they appear in a path: ```text 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 name an exact path inside a skipped directory as a document include, which admits that one path and nothing else, because policy adds coverage and never removes it. 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. Six paths through the classifier: ```text docs/guide.md structured-markdown scanned site/page.mdx structured-mdx scanned README extensionless-markdown scanned llms.txt plain-advisory scanned, nothing extracted vendor/lib/README.md excluded the vendor component is in the closed set src/parser.rs not a document a reference target only ``` 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](https://github.com/HardMax71/amiss/blob/main/spec/examples/frontmatter-vectors.json) execute this boundary, including LF, CRLF, bare CR, BOM, and exact-limit cases, through the production recognizer in the [frontmatter test](https://github.com/HardMax71/amiss/blob/main/crates/amiss-md/tests/frontmatter.rs). 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 claim on the candidate side is an unsupported capability boundary: the run ends incomplete with exit 2. A base-only claim does not. The [governed-definition vectors](https://github.com/HardMax71/amiss/blob/main/spec/examples/governed-definition-vectors.json) drive extraction, source hashing, candidate-only grouping, and report construction in the [governed test](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/tests/governed.rs). Every count is reported: discovered, scanned, unsupported, excluded, unlinked. Despite its historical name, `unlinked-document` means a scanned document from which Amiss extracted zero references. The evaluator does not construct an inbound reachability graph, so the finding does not assert that no other page links to the document. The exact predicate is in the [document finding evaluator](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/evaluate.rs). Paths are treated as bytes. Amiss does not fold case and does not normalize Unicode, because Git addresses files by exact bytes, and a checker that guesses two names are equivalent will eventually insist that two different files are the same file. A name whose bytes are not valid UTF-8 is still a name: the entry is classified by the same suffix rules, scanned, and reported, with its path written as a `bytes_hex` object naming the raw bytes as lowercase hex, since JSON text cannot carry them directly. Only a name outside the path grammar itself, one containing a backslash or a NUL byte, or a bare `.` or `..` segment, is refused. That refusal is never quiet: the run stops as incomplete, the error is recorded as `UNREPRESENTABLE_PATH` with the exact bytes in `path_bytes_hex`, and the exit is 2. Dropping such an entry silently would be the worst bug this tool could have: the report would come back green with a document missing from it, and a missing row is the one defect no reader can notice. 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/tests/discovery.rs) 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. --- # Resolution Parsing turns each document into a list of occurrences: inline links and images, reference style links, and autolinks. Each occurrence keeps two spellings of its destination. The raw one is the exact bytes from the source. The semantic one is what those bytes mean after the format's own decoding. So `[a](&b)` records both `&b` and `&b`, and a change to either the spelling or the meaning is visible later. What the parser cannot see into is declared instead of skipped. Raw HTML blocks and [MDX](https://mdxjs.com) expressions become opaque regions, reported with their size and place as `opaque-html-region` and `opaque-mdx-region` findings, so a link hidden inside JSX is a stated blind spot rather than an invisible one. Each destination then passes through the generic [resolver](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/resolve.rs); trusted absolute forge spellings continue through the private [dialect module](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/resolve/forge.rs). A relative path resolves from the document's own directory and must stay inside the repository; `../../etc/passwd` is an `invalid-reference`, not a file read. A path beginning with `/` is a site route, not a repository-root shorthand, and is reported as unsupported reference semantics. 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 only when it names the candidate branch or, for Gitea, the exact candidate commit. A same-repository URL for any other version is `unsupported-version-scope`; a URL outside that identity is external, and the engine never fetches one. It records the occurrence with the destination the format decodes to, the address a fetcher would request, 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. Three dialects exist, each pinned to the exact URL grammar its forge's browser emits. The github dialect reads `owner/name/blob-or-tree/ref/path` and serves GitHub and any GitHub Enterprise host the identity declares. The gitlab dialect reads the canonical separator form `group[/subgroup...]/name/-/blob-or-tree/ref/path`, nested groups compared whole. The gitea dialect serves Gitea, Forgejo, and Codeberg with typed selectors: `src/branch/` splits like the others, `src/commit/` resolves exactly when its full lowercase object id is the candidate commit and is out of version scope otherwise, and `src/tag/` is always out of version scope because no tag is a trusted ref. Line anchors follow the forge: `#L10-L20` is a line reference on github and gitea, `#L10-20` on gitlab, and each range spelling is nothing on the other's forge. `#L10` is common to all three. 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. One document, every destination shape: ```markdown [guide](guide.md) resolves beside this document [guide](guide) resolves to guide.md, the spelling a router serves [site](/docs/guide.md) unsupported site route; it is not rewritten as a tree path [escape](https://github.com/HardMax71/amiss/blob/main/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 [anchor](guide.md#setup) resolves when a known renderer publishes that heading identity ``` The same decision, drawn: ```dot process digraph resolve { rankdir = LR; node [shape = box, fontname = "Latin Modern, Georgia, serif", fontsize = 11]; edge [fontname = "Latin Modern, Georgia, serif", fontsize = 10, arrowsize = 0.7]; dest [label = "destination"]; rel [label = "relative path"]; route [label = "leading-slash site route"]; forge [label = "forge URL, same repository"]; scope [label = "candidate ref (or Gitea candidate OID)"]; other [label = "any other URL"]; tree [label = "resolve against the tree"]; ext [label = "external, recorded not fetched"]; vers [label = "unsupported-version-scope"]; unsup [label = "unsupported-reference-semantics"]; hit [label = "target bytes and mode read"]; miss [label = "explicit-target-missing"]; decl [label = "target-declared-untracked"]; dest -> rel; dest -> forge [label = "with identity + dialect"]; dest -> route; dest -> other; rel -> tree; forge -> scope; scope -> tree [label = "matches"]; scope -> vers [label = "other version"]; route -> unsup; other -> ext; tree -> hit [label = "found"]; tree -> decl [label = "absent, declared"]; tree -> miss [label = "absent"]; } ``` 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`, and a directory's `index` for its `README.md`. 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. [What a documentation router serves](route-spellings.md) holds the spellings, the routers they were harvested from, and what the union costs. 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 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, and a directory line answers for its descendants. The result 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 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: a path the tree holds never reaches the question. Resolution is exact, and the small rules matter. A trailing slash means the author promised a directory, so `sub/` must be a tree and `guide.md/` is a type mismatch even though `guide.md` exists. Percent-encoding is decoded exactly once: `%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. Ten 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. An anchor no rule publishes is `kind: missing` with `reason: heading-anchor-not-found`, an ordinary missing target. 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. [What ten renderers call a heading](anchor-rules.md) 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, or one the anchor budget cannot afford 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. Only the candidate version is read. `--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; guessing those here would turn honest ignorance into a false pass. The [resolver tests](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/tests/resolve.rs) 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 is `unsupported-target-kind`, because following one leaves the world of exact bytes where the guarantees live. A [Git LFS](https://git-lfs.com) 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. --- # What ten 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. [Resolution](resolution.md) 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 | Rule | Serves | Distinguishing behavior | | --- | --- | --- | | `github` | github.com, GitLab, Docusaurus, Hugo's github type | keeps letters, marks, numbers and connector punctuation; one separator per space; the only rule that also anchors a heading written as raw HTML | | `gitea` | Gitea 1.27 repository files and wiki pages | drops marks; publishes nothing for an empty identity; never suffixes a repeat | | `forgejo` | Forgejo 16 repository files and wiki pages | Gitea's filter, but an empty identity becomes `heading` and repeats take `-1` | | `mdbook` | mdBook with smart punctuation off | Rust's alphanumeric test, so Indic vowel signs survive where Gitea drops them | | `mdbook-smart` | mdBook as it ships | the same, after `--` becomes an en dash and `...` an ellipsis | | `goldmark` | goldmark embedders keeping its own ids | drops every multi-byte rune; `_` becomes a separator; empty becomes `heading` | | `python-markdown` | MkDocs with the default toc slug | NFKD then ASCII fold, so `Café` is `cafe` and CJK is empty; repeats take `_1` | | `pymdownx` | MkDocs configured with `pymdownx.slugs.slugify` | keeps Unicode; one separator per space; repeats take `_1` | | `mdit-vue` | VitePress, VuePress | a wide punctuation class collapses to one separator; a leading digit takes `_` | | `kramdown` | Jekyll, GitHub Pages | strips the leading run of non-letters; ASCII only; empty becomes `section` | 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, and nothing a repository declares can shrink it. 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. An identity can also be written down rather than derived. Raw HTML declares one with `id` or `name`, and the `attr_list` extension declares one with an attribute 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. In MDX the attribute spelling is an expression, so Docusaurus writes the identity as a comment instead, `### \`noIndex\` {/* #noIndex */}`, and takes it as written with its case intact. That comment ends the heading or it declares nothing, which is `parseMarkdownHeadingId`'s own rule and the reason `{/* #id */} after` names nothing. Every declared identity joins the union whatever the renderer, because it is authored rather than derived, and accepting one a given renderer would not publish can only leave a finding unreported, never invent one. 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 `

` 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](https://codeberg.org/forgejo/forgejo), where `

Welcome to Forgejo

` 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](https://github.com/HardMax71/amiss/blob/main/spec/examples/heading-anchor-vectors.json), 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](ledger.md) 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 remaining three are transcribed from Gitea's `CleanValue`, Forgejo's `prefixedIDs`, and mdBook's `id_from_content`. Eight documents, in [`corpus/third_party/anchor-fixtures/`](https://github.com/HardMax71/amiss/tree/main/corpus/third_party/anchor-fixtures), carry what a renderer actually published for them, harvested 2026-07-26: | Document | Renderer | Identities | | --- | --- | ---: | | `probe.md`, this repository's own | github.com file view | 28 | | `probe.md` | mdbook 0.5.4, default configuration | 28 | | `probe.md` | python-markdown 3.10 with `toc` and `attr_list` | 28 | | `probe-html.md`, this repository's own | github.com file view | 9 | | `probe-attr.md`, this repository's own | python-markdown 3.10 with `toc`, `attr_list` and `fenced_code` | 7 | | `probe-mdx-heading.mdx`, this repository's own | `@docusaurus/utils` 3.10.2, `parseMarkdownHeadingId` | 3 | | `awesome-gitea.md`, CC0 | gitea.com | 50 | | `starship-ja.md`, ISC | starship.rs, VitePress | 32 | 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. Three of its seven headings declare an identity 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 syntax does not read. 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 `

` 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. ## 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. The fix for any of them is another row, derived and pinned the same way, since the union only grows. --- # 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](ledger.md) 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](resolution.md) places this in the order; this page holds the spellings and where they came from. ## The three spellings | Spelling | A destination like | Reaches | Served by | | --- | --- | --- | --- | | `extensionless` | `guide` | `guide.md` | vitepress | | `output-extension` | `guide.html` | `guide.md` | mdbook, vitepress | | `readme-index` | `dir/index.md`, `dir/index.html` | `dir/README.md` | mdbook, 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. 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](https://github.com/HardMax71/amiss/blob/main/spec/examples/route-spelling-vectors.json), 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: | Router | Version and configuration | How it answered | | --- | --- | --- | | `mdbook` | 0.5.4, default | the href it emitted, resolved against the built output tree | | `vitepress` | 1.6.4, `cleanUrls` | its own dead-link report, corroborated by the output tree | | `vitepress-readme` | the same, plus the README-to-index rewrites starship configures | the same | | `mkdocs` | 1.6.1, default `use_directory_urls` | its 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. 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 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](anchor-rules.md) 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. Nothing a repository declares selects a router, because a configuration file in the tree would be a lever the pull request under review could pull. 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. For the three that completed, a row is not the answer. 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 `
` 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. The identity is in the tree, but reading it means parsing front matter this engine keeps opaque and then indexing every document by what it declares. Docusaurus itself refused at first, its findings serializing past the output reservation described in [Limits and refusals](limits.md). 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 rule. What Hugo and Jest need instead is the generated class the [roadmap](roadmap.md) still carries, arriving there as transclusion, as a repository's own render hook, and as an identifier that was never a path. --- # 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](https://github.com/HardMax71/amiss/blob/main/spec/examples/correlation-intent-vectors.json) pin those fields and the GitHub, GitLab, and Gitea equivalence rows through the production projection in the [vector test](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/tests/correlation_vectors.rs). 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/tests/correlate.rs) 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 unchanged | dependency changed | | --- | --- | --- | | **block unchanged** | no finding | `dependency-changed-subject-unchanged` | | **block changed** | `subject-changed` | `dependency-and-subject-cochanged` | And the finding the tool exists for, as a change: ```diff fn parse(input: &[u8]) -> Ast { - tokenize(input).fold(Ast::new(), Ast::push) + lex(input).try_fold(Ast::new(), Ast::push).unwrap_or_default() } ``` ```markdown 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. Every normalizer is a parser for someone else's language and each one shipped would be a place for a real change to hide. For the block itself, the compared projection is structural, so re-wrapping a paragraph without changing its text does not create fake impact. --- # The report `--format json` writes exactly one line to stdout: the canonical JSON of the report, then a newline. Canonical means [RFC 8785](https://www.rfc-editor.org/rfc/rfc8785) 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` (`experimental` for the v0 series), 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. 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. 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_hex": "..."}` naming the raw bytes as lowercase hex. A writer never uses the object form for bytes that decode as text, so every derived digest stays whole. An external destination is recorded where it is seen and nowhere else. The occurrence keeps the URL in `external_destination`, after the format's own decoding so that `https://example.com/x?a=1&b=2` is recorded as the address a fetcher would request rather than as the bytes the source spells. No finding is raised, and the summary counts it under `external_out_of_scope`, because the engine never fetched it and so decided nothing. [Amiss and link checkers](comparison.md) shows the one command that turns those rows into a list for 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. `feedback` is the smaller review surface derived by the engine from those exact findings. Related introduced problems become one `fix` per target, changed targets under unchanged prose become one `check`, and `existing_count` counts grouped pre-existing subjects without turning them into items. Each item retains its affected-location count and contributing finding kinds. A Fix may carry one candidate-side text-path annotation; Checks never do. The report retains every item. An incomplete comparison instead emits exactly `{"status":"unavailable"}`, so scan failure cannot look like zero feedback. The envelope, down to its top-level keys: ```json { "schema": "amiss/scanner-report-envelope", "payload": { "schema": "amiss/scanner-report-payload", "compatibility": "experimental", "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: ```json { "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 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. The sentences live in one place, [`FindingKind::meaning` and `AnalysisErrorCode::meaning`](https://github.com/HardMax71/amiss/blob/main/crates/amiss-wire/src/report.rs); the lists in [Profiles and findings](profiles.md) and [Limits and refusals](limits.md) 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 byte outside printable ASCII with a `\uXXXX` escape so a hostile filename cannot inject terminal control codes or a forged CI command into a log, and states any overflow explicitly. It keeps raw totals and prints descriptions only for errors; finding kinds and their descriptions stay in JSON. 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](limits.md). 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 [provider lanes](provider-controls.md) 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. The machine contract is the [current report schema](https://github.com/HardMax71/amiss/blob/main/spec/scanner-report.schema.json), its [readable example](https://github.com/HardMax71/amiss/blob/main/spec/examples/scanner-report.json), and the corresponding [canonical bytes](https://github.com/HardMax71/amiss/blob/main/spec/examples/scanner-report.canonical.json). 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss/tests/documentation_contracts.rs). This is one rolling, unversioned wire contract during the pre-1.0 `experimental` series. Only the unsuffixed schema and examples linked above describe public report output. The schema, examples, parsers, and writer change together. Consumers that need a stable integration must pin an Amiss release and its shipped schema. --- # Limits and refusals The report has a closed set of named resource ceilings. Crossing a measured ceiling produces a typed error carrying the wire resource name, configured limit, and observed lower bound; a run that cannot complete exits 2. 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](security.md). 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. 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. | Report resource | Limit | | --- | ---: | | `git-object-bytes` | 134,217,728 | | `git-compressed-object-bytes` | 268,435,456 | | `aggregate-git-compressed-object-bytes-per-evaluation` | 2,147,483,648 | | `git-pack-directory-entries` | 8,192 | | `git-pack-files` | 4,096 | | `git-pack-index-bytes` | 536,870,912 | | `aggregate-git-pack-index-bytes` | 1,073,741,824 | | `git-delta-depth` | 128 | | `git-index-bytes` | 268,435,456 | | `git-tree-entries-per-snapshot` | 1,000,000 | | `documents-per-snapshot` | 100,000 | | `control-input-bytes` | 16,777,216 | | `selected-control-blob-bytes` | 16,777,216 | | `aggregate-selected-control-bytes-per-snapshot` | 67,108,864 | | `repository-policy-entries` | 100,000 | | `debt-items` | 100,000 | | `waiver-items` | 100,000 | | `raw-path-bytes` | 4,096 | | `document-blob-bytes` | 4,194,304 | | `referenced-target-blob-bytes` | 16,777,216 | | `aggregate-referenced-target-bytes-per-snapshot` | 536,870,912 | | `ignore-declaration-blob-bytes` | 1,048,576 | | `aggregate-ignore-declaration-bytes-per-snapshot` | 16,777,216 | | `aggregate-line-fragment-evaluation-bytes-per-snapshot` | 536,870,912 | | `aggregate-heading-anchor-evaluation-bytes-per-snapshot` | 536,870,912 | | `aggregate-document-bytes-per-snapshot` | 536,870,912 | | `raw-link-destination-bytes` | 16,384 | | `parser-nesting` | 256 | | `parser-nodes-per-document` | 250,000 | | `parser-nodes-per-snapshot` | 5,000,000 | | `aggregate-embedded-code-evaluation-bytes-per-snapshot` | 536,870,912 | | `references-per-document` | 16,384 | | `references-per-snapshot` | 1,000,000 | | `organization-policy-entries` | 100,000 | | `complete-findings` | 100,000 | | `typed-analysis-errors-retained` | 64 | | `machine-json-bytes` | 268,435,456 | | `private-temporary-storage-bytes` | 67,108,864 | | `evaluator-managed-memory-bytes` | 1,073,741,824 | Two 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, so fastapi serializes 33 MB and Docusaurus 53 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 at the leanest finding this engine builds a hundred thousand of them fit under the reservation, so that counter is what stops a findings flood and the reservation backstops anything heavier. 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](status.md). 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: ```json { "code": "RESOURCE_LIMIT_EXCEEDED", "phase": "git", "resource": "raw-path-bytes", "configured_limit": 4096, "observed_lower_bound": 5008 } ``` Both numbers travel with the error, so the reader knows how far past the ceiling the input went without rerunning anything. Refusals follow one rule: when the input cannot be trusted, no 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 document whose bytes will not decode, a name outside the path grammar, a control file with a duplicated JSON key: each has a named error code (`GIT_OBJECT_MISSING`, `DOCUMENT_INVALID`, `UNREPRESENTABLE_PATH`, and the rest of a closed list), and each ends the run at exit 2. A name that is merely not UTF-8 is not on that list: it is an ordinary document whose path the report writes as hex. The alternative in every one of these cases is a report that looks complete and is not. The closed list, one fixed sentence per code, generated from [`AnalysisErrorCode::meaning`](https://github.com/HardMax71/amiss/blob/main/crates/amiss-wire/src/report.rs) and checked in CI. The human output prints the same sentence as a `note` line whenever a code appears, so an exit-2 log says how to unblock the run without this page open. - `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, or default-branch identity is not in canonical form; pass a lowercase owner and name and full refs/heads/ references - `INVALID_PROFILE`: the profile is not one of observe or enforce - `REQUEST_UNREADABLE`: the machine evaluation request bytes could not be read; nothing was evaluated - `CONFIGURATION_INVALID`: a policy or control input violates its schema; 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; strict parsing refuses the file instead of 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 - `UNKNOWN_SCHEMA`: a JSON input declares a schema identifier this engine does not recognize - `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 - `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 and the run ends incomplete - `EXCEPTION_OVERLAP`: accepted exception items select the same finding more than once; overlap ends evaluation incomplete instead of double-suppressing - `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 - `GIT_REPOSITORY_UNAVAILABLE`: the --repo path does not open as a Git repository of the declared 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 - `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 - `GIT_INDEX_INVALID`: the staged index file does not parse under the index grammar - `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 discovered document's bytes cannot be decoded as its format requires; the run refuses instead of skipping the file and passing - `PARSER_ERROR`: the pinned parser failed on a document; the document is named and the run is incomplete rather than the file silently dropped - `PARSER_PANIC`: the pinned parser panicked on a document; the panic is caught and reported, and the run is incomplete - `INVALID_SOURCE_SPAN`: the parser returned a node whose byte span does not address the document; the parse is not trusted - `RESOLUTION_ERROR`: reference resolution failed internally; the run ends incomplete rather than reporting around the gap - `RESOURCE_LIMIT_EXCEEDED`: a named resource crossed its ceiling; 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; the run ends incomplete instead of shortening the findings - `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 - `REPORT_CONSTRUCTION_FAILED`: the report could not be constructed or emitted; the run has no trustworthy output - `SANDBOX_VIOLATION`: the run breached its sandbox descriptor; the result is not trustworthy - `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 - `INTERNAL_ERROR`: an engine invariant failed; this is a defect in Amiss, not in the input, and the run has no trustworthy result --- # 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss/tests/no_write.rs) 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](snapshots.md). 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/scan.rs). 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 the one measured quadratic case. The history of that case is in the [corpus notes](https://github.com/HardMax71/amiss/blob/main/corpus/README.md). 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 escape of its value, never inventing a character the bytes never encoded. The JSON report keeps fidelity its own way, the exact original string for a UTF-8 path and a `bytes_hex` 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](https://github.com/HardMax71/amiss/blob/main/action.yml) makes conventional source release tags usable by delegating to the same version's immutable [`action/vX.Y.Z` runtime](https://github.com/HardMax71/amiss/blob/main/crates/amiss/action/runtime.yml). 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-bootstrap/tests/release.rs); that prevents a format-level `github.com` assumption but does not authenticate the supplied identity. The separately executable [`amiss-bootstrap`](https://github.com/HardMax71/amiss/blob/main/crates/amiss-bootstrap/src/main.rs) 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/`](https://github.com/HardMax71/amiss/tree/main/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](controller.md) 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, execution constraint, optional controls, bootstrap, TLS terminator, scratch directory, raw inbox where used, and delivery ledger 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 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](provider-controls.md) 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. --- # Controls and policy Two kinds of configuration can shape a run, and they carry opposite amounts of trust. The repository policy is the one input read from the scanned tree itself, and it is correspondingly weak. `.amiss/scanner-policy.json` can add directories to scan, list protected paths whose removal is always a finding, and raise 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. 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. Each snapshot policy can carry the [published repository-policy entry ceiling](limits.md), so the base/candidate classification union can contain twice that many distinct roots. The [`policy` tests](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/tests/policy.rs) pin the semantic boundaries, and the release [`eligibility` test](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/tests/eligibility.rs) checks the maximum union without scanning every policy row for every discovered path. The `amiss-scan` `controls` benchmark tracks both tree 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: an organization floor (tightens ceilings and dispositions across many repositories), an adoption debt snapshot (a recorded list of known failures being worked off), a waiver bundle (time-limited permission to pass despite a named failure), trusted time, and an execution constraint. 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](https://github.com/HardMax71/amiss/blob/main/spec/organization-floor.schema.json), [debt-snapshot](https://github.com/HardMax71/amiss/blob/main/spec/debt-snapshot.schema.json), and [waiver-bundle](https://github.com/HardMax71/amiss/blob/main/spec/waiver-bundle.schema.json) schemas, the [control parsers](https://github.com/HardMax71/amiss/blob/main/crates/amiss-wire/src/controls.rs), and their [open-forge contract tests](https://github.com/HardMax71/amiss/blob/main/crates/amiss-wire/tests/controls.rs) 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-wire/src/controls/execution_constraint.rs). 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-wire/src/controls/trusted_time.rs) 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/policy.rs) 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](controller.md). The concrete [provider lanes](provider-controls.md) 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/pipeline/external.rs). 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. 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. The [wrapper tests](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/tests/wrapper.rs) 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](limits.md) 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](https://github.com/HardMax71/amiss/blob/main/spec/scanner-evaluation-request.schema.json) and [controls-request schema](https://github.com/HardMax71/amiss/blob/main/spec/scanner-controls-request.schema.json), with matching [strict parser tests](https://github.com/HardMax71/amiss/blob/main/crates/amiss-wire/tests/requests.rs). Their unversioned names are intentional: before 1.0 the shipped schema, parser, examples, and report form one rolling contract and move together. In the public command and GitHub composite Action, all five external controls are absent and no protected target ref is authenticated. 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/report.rs). The sealed bootstrap path can now carry all five controls 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; and the candidate identity and honest sandbox projection. The public [CLI shell](https://github.com/HardMax71/amiss/blob/main/crates/amiss/src/main.rs) still supplies each value as `None`. 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. --- # 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 family | Required provider gate | Amiss evidence | Supported deployment | | --- | --- | --- | --- | | GitHub | Strict required check bound to one GitHub App | App-owned Check Run on the test-merge commit | GitHub.com and compatible GHES | | GitLab | Enforced merge train plus an independently owned pipeline execution policy job | The policy job succeeds only after the exact train result passes | GitLab 19.3 or newer, Ultimate | | Gitea | One required approval restricted to a dedicated reviewer | That reviewer approves or requests changes on the checked pull request | Gitea 1.27 or newer | | Forgejo | One required approval restricted to a dedicated reviewer | That reviewer approves or requests changes on the checked pull request | Forgejo 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: - [GitHub provider lane](provider-github.md) - [GitLab provider lane](provider-gitlab.md) - [Gitea and Forgejo provider lane](provider-gitea.md) ## 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. ```dot process digraph provider_controls { rankdir = LR; node [shape = box, fontname = "Latin Modern, Georgia, serif", fontsize = 11]; edge [arrowsize = 0.7, fontname = "Latin Modern, Georgia, serif", fontsize = 10]; source [label = "provider-created\nrequest"]; auth [label = "authenticate\noutside repo"]; first [label = "refresh exact\nchange + gate"]; fetch [label = "acquire exact\nrepo + action"]; boot [label = "sealed\nbootstrap"]; final [label = "refresh gate\nagain"]; save [label = "save exact\nresult"]; proof [label = "provider merge\nevidence"]; source -> auth -> first -> fetch -> boot -> final -> save -> proof; } ``` 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/`](https://github.com/HardMax71/amiss/tree/main/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 or ledger 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 `GET` endpoints: | Path | Contract | | --- | --- | | `/healthz` | Returns `200` while the HTTP process can answer. It is liveness only. | | `/readyz` | Returns `200` only after local initialization, and `503` before readiness or during drain. | | `/metrics` | Returns 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 ten label-free counters: | Counter | Counts | | --- | --- | | `amiss_controller_provider_requests_total` | Configured provider `POST` requests answered. | | `amiss_controller_provider_acceptances_total` | Provider requests accepted for durable or synchronous work. | | `amiss_controller_provider_refusals_total` | Provider requests refused by authentication, bounds, request shape, or policy. | | `amiss_controller_provider_unavailable_total` | Provider requests that returned an unavailable result. | | `amiss_controller_delivery_attempts_total` | Durable deliveries attempted by a webhook worker. | | `amiss_controller_delivery_completions_total` | Durable deliveries completed. | | `amiss_controller_delivery_retries_total` | Durable deliveries left for retry. | | `amiss_controller_delivery_discards_total` | Durable deliveries removed after failed reauthentication. | | `amiss_controller_maintenance_runs_total` | Ledger maintenance scans completed. | | `amiss_controller_maintenance_removals_total` | Durable records, reports, and temporary entries removed by maintenance. | 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. 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. ```json {"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 through it; keep `/healthz`, `/readyz`, and `/metrics` private. None of the three operator endpoints is authenticated. ## 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, scratch directory, and file-ledger 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](execution-constraint.md). 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](#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: | Ceiling | Value | | --- | --- | | Request body | 8 MiB | | Header count | 128 | | Aggregate header bytes | 32 KiB | | Ledger rows | 100,000 | | In-process endpoint concurrency | 64 | | Webhook inbox rows | 1,024 | | Webhook inbox total | 128 MiB | | One webhook inbox row | 16 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. A result is 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 uses bounded, checksummed ordinary files and atomic replacement; it has no SQL or 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. --- # 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: ```sh 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: ```sh 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 value | Meaning | | --- | --- | | Action checkout | The local primary checkout that already contains the action commit. | | Action identity | The forge host and repository whose action tree is trusted. | | Action commit | The independently selected full commit ID already present in the local object store. | | Bootstrap | The exact local executable that the service will later load. | | Required status name | The stable result name bound into controller state and the report. | The rest comes from those exact bytes: | Derived value | Source | | --- | --- | | Object format | The current provider contract's fixed SHA-1 namespace, confirmed while reading the commit. | | Action tree | The tree named by that commit object. | | Manifest path | The release's fixed `release-manifest.json` path. | | Manifest digest | The parsed manifest's semantic digest. | | Target platform | The bootstrap executable header. | | Bootstrap digest | The domain-separated digest of the bootstrap bytes. | | Schema, bootstrap contract, descriptor digest | The existing wire constructor and canonical writer. | The tool resolves the manifest, dependency locks, engine, launcher, 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. --- # GitHub provider lane The unpublished [`amiss-controller-github-service`](https://github.com/HardMax71/amiss/tree/main/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, verifies GitHub's HMAC over the exact body, decodes only supported pull-request events, checks the configured repository, target branch, and plan, then saves the raw request before returning `202 Accepted`. The worker authenticates the saved bytes again before use. ```dot process digraph provider_controls { rankdir = TB; node [shape = box, fontname = "Latin Modern, Georgia, serif", fontsize = 11]; edge [arrowsize = 0.7, fontname = "Latin Modern, Georgia, serif", fontsize = 10]; github [label = "GitHub\nsigned pull-request event"]; tls [label = "TLS terminator"]; gate [label = "bounded receiver\n+ body HMAC"]; inbox [label = "raw inbox\nordinary files"]; claim [label = "delivery record\nclaim"]; api1 [label = "GitHub App refresh\n+ strict effective rules\n+ test merge"]; fetch [label = "acquire exact\nrepository + action"]; boot [label = "supervised bootstrap\n+ sealed engine"]; api2 [label = "final refresh"]; save [label = "save exact result"]; check [label = "App-owned\nCheck Run"]; done [label = "mark delivery done"]; { rank = same; github; tls; gate; inbox; } { rank = same; claim; api1; fetch; boot; } { rank = same; api2; save; check; done; } github -> tls -> gate -> inbox; claim -> api1 -> fetch -> boot; api2 -> save -> check -> done; inbox -> claim [constraint = false]; boot -> api2 [constraint = false]; github -> claim -> api2 [style = invis]; } ``` 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 do not create work. 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, 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`](file-ledger.md) 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: | Permission | Access | Why | | --- | --- | --- | | Metadata | Read | Read repository identity and effective rules. | | Contents | Read | Fetch the exact repository and pinned action objects. | | Pull requests | Read | Refresh the authenticated pull request. | | Checks | Read and write | Read and create the App-owned Check Run. | | Commit statuses | Read and write | Make the App available as the selected source when configuring the required status. | Subscribe the App to the `pull_request` webhook event, 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. 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](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets). The service reads the [effective rules for the branch](https://docs.github.com/en/rest/repos/rules), 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](execution-constraint.md) 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: ```sh cargo build --release --locked \ -p amiss-controller-github-service --bin amiss-controller-github ``` Pre-create the private state and scratch directories, then run the shared [offline configuration check](provider-controls.md#offline-configuration-check): ```sh target/release/amiss-controller-github --check /etc/amiss/github.json ``` Start the service with the same absolute config path: ```sh 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. Keep the probes and metrics private, and use the shared [service operation](provider-controls.md#service-operation) contract for readiness, redacted lifecycle events, counters, and graceful drain. The delivery endpoint returns: | Status | Meaning | | --- | --- | | `202` | The authenticated raw request is durable, or the exact request was already saved. | | `400` | The request shape, path query, or stored delivery is invalid. | | `401` | Authentication failed. | | `403` | The signed event names another repository, target, or plan. | | `408` | The request body did not finish within 30 seconds. | | `409` | The same source identity was reused for different bytes. | | `413` | The body limit was crossed. | | `431` | The header count or byte limit was crossed. | | `503` | The 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 three 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. ```json { "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", "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" } } ``` 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. `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, 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: ```json { "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 } } } ``` Each omitted field uses its default: | Section | Fields | Defaults | | --- | --- | --- | | `execution` | `body_bytes`, `header_count`, `header_bytes` | 2 MiB, 64, 32 KiB | | `execution` | `queue_age_seconds`, `future_skew_seconds` | 86,400, 5 | | `execution` | `ledger_lease_seconds`, `ledger_records` | 60, 50,000 | | `execution` | `api_connect_millis`, `api_read_millis`, `api_write_millis` | 5,000, 15,000, 15,000 | | `execution` | `api_request_millis`, `git_request_seconds` | 20,000, 120 | | `execution` | `bootstrap_seconds`, `statement_validity_seconds` | 120, 300 | | `queue` | `max_concurrent_deliveries` | 16 | | `queue` | `inbox_lease_seconds`, `inbox_records` | 600, 64 | | `queue` | `inbox_bytes`, `inbox_record_bytes` | 128 MiB, 3 MiB | | `queue` | `retry_min_millis`, `retry_max_millis`, `idle_poll_millis` | 1,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, 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, 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. 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 resource | Fixed limit | | --- | --- | | Pack bytes | 2 GiB | | Declared objects | 2,000,000 | | One inflated stream or resolved object | 128 MiB | | All inflated streams | 4 GiB | | All resolved objects | 4 GiB | | Delta depth | 128 | | Reference deltas (`REF_DELTA`) | Rejected | | Pack indexing | One 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 Both state stores use checksummed ordinary files with bounded rows 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, control files, bootstrap, scratch root, inbox, ledger, 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. | Controller result | GitHub Check Run conclusion | | --- | --- | | Pass | `success` | | Block | `failure` | | Unavailable | `failure` | | Superseded while its staged gate is still current | `cancelled` | The Check Run summary names the provider, repository, change, provider run, gate commit, refs, commits, trees, plan, execution constraint, and report digest. An unavailable result also carries one stable `failure` label such as `timeout` or `tampered-runtime`. 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](https://docs.github.com/en/rest/checks/runs) 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. --- # GitLab provider lane The unpublished [`amiss-controller-gitlab-service`](https://github.com/HardMax71/amiss/tree/main/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. ## Flow The request body contains only the merge request's project-local number, which GitLab calls its IID: ```json {"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`. ```dot process digraph gitlab_provider { rankdir = TB; node [shape = box, fontname = "Latin Modern, Georgia, serif", fontsize = 11]; edge [arrowsize = 0.7, fontname = "Latin Modern, Georgia, serif", fontsize = 10]; policy [label = "independent pipeline\nexecution policy"]; train [label = "enforced merge train\n+ injected job"]; tls [label = "TLS terminator"]; oidc [label = "OIDC claims\n+ bounded request"]; first [label = "refresh job, train,\nchange + rule"]; fetch [label = "acquire exact\nrepo + action"]; boot [label = "sealed\nbootstrap"]; final [label = "refresh gate\nagain"]; save [label = "save exact\nresult"]; result [label = "204 only for pass;\npolicy job succeeds"]; { rank = same; policy; train; tls; oidc; } { rank = same; first; fetch; boot; } { rank = same; final; save; result; } policy -> train -> tls -> oidc; first -> fetch -> boot; final -> save -> result; oidc -> first [constraint = false]; boot -> final [constraint = false]; policy -> first -> final [style = invis]; } ``` 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. A second refresh performs the same checks before the saved result is accepted. ## GitLab project Use a SHA-1 project on a root-mounted HTTPS GitLab instance. 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](https://docs.gitlab.com/ci/pipelines/merge_trains/) and [protected-branch](https://docs.gitlab.com/api/protected_branches/) 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](https://docs.gitlab.com/user/application_security/policies/pipeline_execution_policies/) whose complete shape is: ```yaml 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](https://docs.gitlab.com/ci/secrets/id_token_authentication/) with the exact audience configured in the service. Its only security decision is the service response. A minimal shape is: ```yaml amiss:policy: stage: .pipeline-policy-post image: registry.example/security/http-client@sha256: 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 ``` 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](execution-constraint.md) for the exact action commit, bootstrap, and stable controller result name used by this lane. Build the service from source: ```sh cargo build --release --locked \ -p amiss-controller-gitlab-service --bin amiss-controller-gitlab ``` Pre-create the private scratch and ledger directories, then run the shared [offline configuration check](provider-controls.md#offline-configuration-check): ```sh target/release/amiss-controller-gitlab --check /etc/amiss/gitlab.json ``` Start the service with the same absolute config path: ```sh target/release/amiss-controller-gitlab /etc/amiss/gitlab.json ``` Before binding the listener, the service opens, validates, migrates, and cleans the ledger root. Each admitted policy job then gets a fresh fenced owner session from that prepared root. A normal request does not repeat full-root maintenance, so separate evaluations can remain concurrent. After startup, the service runs the same cleanup once per minute outside request handling. 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](file-ledger.md#frames-and-replacement) 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. Keep the probes and metrics private, and use the shared [service operation](provider-controls.md#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 and ledger roots must already exist as separate real directories outside the repository and action trees, and the bootstrap must match the loaded execution constraint. ```json { "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", "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" } } ``` `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: | Fields | Defaults | | --- | --- | | `body_bytes`, `header_count`, `header_bytes` | 2 MiB, 64, 32 KiB | | `queue_age_seconds`, `future_skew_seconds` | 86,400, 5 | | `ledger_lease_seconds`, `ledger_records` | 60, 50,000 | | `api_connect_millis`, `api_read_millis`, `api_write_millis` | 5,000, 15,000, 15,000 | | `api_request_millis`, `git_request_seconds` | 20,000, 120 | | `bootstrap_seconds`, `statement_validity_seconds` | 120, 300 | `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](provider-github.md#configuration). 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, 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. ## HTTP result and replay The endpoint returns: | Status | Meaning | | --- | --- | | `204` | The exact result was saved, the final refresh still matched, and the conclusion was pass. | | `400` | The configured endpoint was called with a query string. | | `401` | The merge-request hint or OIDC bearer token, signature, key, time, or required claims were invalid. | | `403` | The authenticated request did not select the configured provider route or check plan. | | `408` | The request body did not finish within 30 seconds. | | `412` | The controller completed without an exact published pass, including block, unavailable, stale, busy, or duplicate work. | | `413` | The body limit was crossed. | | `431` | The header count or byte limit was crossed. | | `503` | The service is unready, or capacity, trusted time, storage, provider access, acquisition, or evaluation was unavailable. | 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 refreshes the same job and gate one last time, stages that exact result 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. 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 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. --- # Gitea and Forgejo provider lane The unpublished [`amiss-controller-gitea-service`](https://github.com/HardMax71/amiss/tree/main/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. ```dot process digraph gitea_provider { rankdir = TB; node [shape = box, fontname = "Latin Modern, Georgia, serif", fontsize = 11]; edge [arrowsize = 0.7, fontname = "Latin Modern, Georgia, serif", fontsize = 10]; forge [label = "signed pull-request\nevent"]; tls [label = "TLS terminator"]; inbox [label = "authenticate\n+ raw inbox"]; first [label = "refresh PR,\nreviewer + rule"]; fetch [label = "acquire exact\nrepo + action"]; boot [label = "sealed\nbootstrap"]; final [label = "refresh gate\nagain"]; save [label = "save exact\nresult"]; review [label = "dedicated reviewer\napproval or rejection"]; { rank = same; forge; tls; inbox; } { rank = same; first; fetch; boot; } { rank = same; final; save; review; } forge -> tls -> inbox; first -> fetch -> boot; final -> save -> review; inbox -> first [constraint = false]; boot -> final [constraint = false]; forge -> first -> final [style = invis]; } ``` 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, refreshes everything again, saves the result, and posts or reuses one exact review. | Controller result | Review | | --- | --- | | Pass | `APPROVED` | | Block | `REQUEST_CHANGES` | | Unavailable | `REQUEST_CHANGES` | | Stale or closed before publication | No new review | The review body binds the evaluation, conclusion, provider, repository, pull request, provider run, refs, commits, trees, plan, execution constraint, and report digest. 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. ## 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 12.0.4, `/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. 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](https://forgejo.org/docs/latest/user/token-scope/). 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](https://docs.gitea.com/usage/access-control/protected-branches) and [Forgejo branch-protection](https://forgejo.org/docs/latest/user/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](https://docs.gitea.com/usage/repository/webhooks) and [Forgejo](https://forgejo.org/docs/latest/user/webhooks/) 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](execution-constraint.md) for the exact action commit, bootstrap, and review label used by this lane. Build the service from source: ```sh cargo build --release --locked \ -p amiss-controller-gitea-service --bin amiss-controller-gitea ``` Pre-create the private scratch, inbox, and ledger directories, then run the shared [offline configuration check](provider-controls.md#offline-configuration-check): ```sh target/release/amiss-controller-gitea --check /etc/amiss/gitea.json ``` Start the service with the same absolute config path: ```sh 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](provider-controls.md) in front. Keep the probes and metrics private, and use the shared [service operation](provider-controls.md#service-operation) contract for readiness, redacted lifecycle events, counters, and graceful drain. The delivery endpoint returns: | Status | Meaning | | --- | --- | | `202` | The authenticated raw request is durable, or the exact request was already saved. | | `400` | The request shape, path query, or stored delivery is invalid. | | `401` | Authentication failed. | | `403` | The signed event names another repository, target, or plan. | | `408` | The request body did not finish within 30 seconds. | | `409` | The same source identity was reused for different bytes. | | `413` | The body limit was crossed. | | `431` | The header count or byte limit was crossed. | | `503` | The 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. ```json { "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", "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" } } ``` 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: ```json { "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](provider-github.md#configuration). `execution` covers ingress, ledger, provider HTTP, Git, and bootstrap bounds. `queue` covers webhook concurrency, the raw inbox, retry, and polling. `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, and 64-concurrent-delivery ceilings apply here too. 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](provider-github.md#configuration). ## State, replay, and rotation The inbox and ledger use bounded checksummed files, not SQL or an embedded database. One process owns an inbox. The worker removes raw bytes only after controller completion; the ledger retains the exact result and permanent body-replay marker. 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. 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. --- # 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`](https://github.com/HardMax71/amiss/blob/main/controller/src/orchestration/ledger.rs) is the coordination interface for that record. It is a behavior contract rather than a required storage format. [`FileLedger`](https://github.com/HardMax71/amiss/blob/main/controller/src/file_ledger.rs) is its first durable implementation; it uses ordinary files, not SQL or a database. This record is separate from [the scan ledger](ledger.md), which records project research, and from the repository-owned review memory rejected in [Provenance](provenance.md). 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`](https://github.com/HardMax71/amiss/blob/main/controller/src/ingress/policy.rs) 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 input | What is authenticated | Replay key | Time rule | | --- | --- | --- | --- | | GitHub `X-Hub-Signature-256` | HMAC-SHA256 over the exact body | Domain-separated digest of the exact body | Replay-only; there is no signed delivery-attempt timestamp header. | | Gitea-family `X-Gitea-Signature` or `X-Forgejo-Signature` | HMAC-SHA256 over the exact body | Domain-separated digest of the exact body | Replay-only; there is no signed delivery-attempt timestamp header. | | GitLab Standard Webhooks | HMAC-SHA256 over `webhook-id.webhook-timestamp.body` | Signed `webhook-id` | `SignedTimePolicy::Required(max_age)`; replay-only is not a valid GitLab route. | | GitLab policy-job OIDC | RS256 token with exact issuer, audience, policy origin, project, job, pipeline, runner, train commit, issue time, and `jti` | Domain-separated runner ID and `jti` digest | Required signed age plus token `nbf` and `exp`; replay-only is invalid. | This follows the providers' published contracts: [GitHub signs the payload body](https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries), [Gitea signs the raw body](https://docs.gitea.com/usage/repository/webhooks), and [GitLab's signing token follows Standard Webhooks](https://docs.gitlab.com/user/project/integrations/webhooks/). The matching library code is split into the [GitHub](https://github.com/HardMax71/amiss/blob/main/controller/src/webhook/github.rs), [Gitea-family](https://github.com/HardMax71/amiss/blob/main/controller/src/webhook/gitea.rs), and [GitLab](https://github.com/HardMax71/amiss/blob/main/controller/src/webhook/gitlab.rs) 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](https://docs.gitlab.com/ci/secrets/id_token_authentication/) through the [`GitLabOidc`](https://github.com/HardMax71/amiss/blob/main/controller/gitlab/src/oidc.rs) 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](provider-controls.md). ## 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. ```dot process digraph controller_delivery { rankdir = TB; node [shape = box, fontname = "Latin Modern, Georgia, serif", fontsize = 11]; edge [arrowsize = 0.7, fontname = "Latin Modern, Georgia, serif", fontsize = 10]; raw [label = "raw delivery"]; gate [label = "bounds + receipt time"]; auth [label = "authenticate\n+ replay identity"]; claim [label = "claim"]; first [label = "first refresh\n+ renew"]; run [label = "run if active\n+ heartbeat\n+ renew"]; second [label = "final refresh\n+ renew"]; stage [label = "save exact result"]; publish [label = "publish"]; complete [label = "mark done"]; { rank = same; raw; gate; auth; claim; } { rank = same; first; run; second; } { rank = same; stage; publish; complete; } raw -> gate -> auth -> claim; first -> run -> second; stage -> publish -> complete; claim -> first [constraint = false]; second -> stage [constraint = false]; claim -> publish [label = "saved retry\ncheck binding", constraint = false]; raw -> first -> stage [style = invis]; } ``` 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. The second refresh checks the same identity, gate revision, and current authorization before the result is saved. 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. | Part | Logical value | Rule | | --- | --- | --- | | Delivery key | Provider namespace, provider instance, integration ID, delivery ID | Names one authenticated provider delivery. | | Fixed binding | Repository, change, provider run ID and attempt, object format, event candidate commit | Reusing the key with a different binding fails before refresh, run, or publication. | | Replay lifetime | Permanent, or an inclusive replay end based on authenticated time | Decided by trusted ingress and stored with the fixed binding. Only an ended bounded lifetime can permit deletion. | | Evaluation ID | Opaque controller-created ID with fresh random bytes | Created on the first claim and kept through retries and reclaims. A later row cannot reuse it. | | Temporary ownership | Evaluation ID, lease deadline, fence | Grants permission to evaluate; the record, not a worker's clock, decides whether it is still live. | | Saved result | Evaluation ID, check-plan binding, fence, provider run, full run identity, provider gate commit, conclusion, optional report | Frozen as one exact value before provider I/O. | | State | New, running, result saved, done | Each 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](file-ledger.md). The public Rust boundary has four operations. This abridged excerpt omits documentation and type bounds; the [ledger module](https://github.com/HardMax71/amiss/blob/main/controller/src/orchestration/ledger.rs) is authoritative. ```rust pub trait DeliveryLedger { type Error; fn claim(&mut self, delivery: &AcceptedDelivery) -> Result; fn renew(&mut self, delivery: &AcceptedDelivery, lease: &DeliveryLease) -> Result; fn stage( &mut self, delivery: &AcceptedDelivery, lease: &DeliveryLease, publication: &Publication, ) -> Result; fn complete(&mut self, delivery: &AcceptedDelivery, staged: &StagedPublication) -> Result; } ``` `claim` is the one entry point for new work and retries: | Result | Plain meaning | Controller action | | --- | --- | --- | | `Execute` | This caller has a live lease. | Refresh and evaluate. | | `Publish` | An exact result was already saved. | Check its binding, then publish it without refreshing or running again. | | `Busy` | Another live claim currently owns the work. | Return the evaluation ID and retry time; do no provider or runner work. | | `Duplicate` | The saved result was published and marked done. | Do nothing. | | `BindingConflict` | The 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. ```dot process digraph delivery_states { rankdir = TB; node [shape = box, fontname = "Latin Modern, Georgia, serif", fontsize = 11]; edge [arrowsize = 0.7, fontname = "Latin Modern, Georgia, serif", fontsize = 10]; fresh [label = "new"]; running [label = "running\nlease + fence"]; saved [label = "result saved\nexact value"]; done [label = "done"]; fresh -> running [label = "claim: Execute"]; running -> running [label = "renew: same fence\nexpired reclaim: higher fence"]; running -> saved [label = "stage"]; saved -> saved [label = "claim: Publish\npublish error: retry"]; saved -> done [label = "publish OK\nthen complete"]; done -> done [label = "claim: 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. [Provider-verified controls](provider-controls.md) records that provider limit. | Stop point | What the next claim sees | Safe next action | | --- | --- | --- | | During a live run | `Busy`, or `Execute` after expiry | Wait, or evaluate again with the same evaluation ID and a higher fence. | | After saving, before publication | `Publish` | Publish the exact saved value. | | Provider accepted, but its reply was lost | `Publish` | Repeat the same provider update. | | After publication, while completion is unclear | `Publish` or `Duplicate` | Repeat the same update if needed, then complete the exact saved value. | | After completion | `Duplicate` | Do 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 | Condition | Required behavior | | --- | --- | | Raw ceilings, receipt time, signature, anchor window, route, trust set, or required signed request time fails | Reject the delivery before claiming it. | | The key has a different authenticated binding | Reject it before provider or runner work. | | A saved result does not match the authenticated delivery | Reject it before provider I/O. | | Another claim is live | Report in progress and do no work. | | Lease renewal cannot prove ownership | Stop, discard runner output, and do not publish it. | | A valid refresh for the same delivery reports closure or revocation | Save 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 commit | Save `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 candidate | Reject it without saving or publishing a result. | | Runner output is missing, timed out, too large, tampered with, or bound to the wrong identity or tree | Save and publish the matching unavailable result without a report. | | Atomic stage loses the fence race | Make no publication call. | | Publication fails or its acknowledgement is unclear | Keep the exact saved result for another publication attempt. | | Completion cannot be confirmed | Report 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. 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`, worker, orchestrator, acquisition boundary, and supervised bootstrap runner. Focused tests cover ingress limits and tampering, replay, rotation and revocation, file corruption, cross-process ownership, reclaim, exact publication retry, full roots, clock rollback, runner timeout, process descendants, and output replacement. 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](provider-controls.md) 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. --- # 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](controller.md). 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: ```text .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) .state .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. The older v2 schema contains no check-plan binding, so it is rejected instead of attaching a caller-supplied policy to old work; a future schema change needs an explicit migration that preserves every stored authorization field. 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 state | Cleanup rule | | --- | --- | | `running` | Keep 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`, permanent | Keep the small state marker; it is the replay defense. | | `done`, bounded | Keep 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. --- # Architecture The engine has six production crates, and trust flows in one direction; a seventh exists only for tests. The unpublished provider-controller crates share the same workspace and depend on the engine, never the other way round. ```dot process digraph amiss { rankdir = BT; node [shape = box, fontname = "Latin Modern, Georgia, serif", fontsize = 11]; edge [arrowsize = 0.7]; wire [label = "amiss-wire\ncanonical JSON, digests,\nschemas, machine contracts"]; git [label = "amiss-git\nobject store, packs, index,\nno-follow handles"]; md [label = "amiss-md\npinned document parsers"]; scan [label = "amiss-scan\ndiscovery, resolution,\ncorrelation, evaluation, policy"]; cli [label = "amiss\nthe engine binary"]; boot [label = "amiss-bootstrap\nverified-run wrapper"]; git -> wire; md -> wire; scan -> git; scan -> md; scan -> wire; cli -> scan; cli -> git; cli -> wire; boot -> git; boot -> wire; } ``` The graph above is the root workspace. `amiss-wire` is its foundation: strict JSON with canonical output, the digest rules, the report format, and every machine contract. Nothing in it knows what a repository is. `amiss-git` reads Git storage behind the never-follow-links boundary: loose objects, packs, deltas, and the index, each under a parser that rejects malformed input and a published resource ceiling. It repairs nothing. `amiss-md` holds the document parsers, pinned against the official [CommonMark](https://commonmark.org) and [GFM](https://github.github.com/gfm/) test suites plus the [MDX](https://mdxjs.com) grammar's own tests. The pin is a checked-in manifest recording node counts, extraction results, and byte positions for every test case. A parser change that moves any of those moves the manifest, and review sees the diff. `amiss-scan` is the evaluation itself: discovery, resolution, correlation, the base-versus-candidate comparison, policy, and report construction. It is a library that does no I/O beyond the store handed to it. It also carries the ten heading-identity rules, each 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 two 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](status.md) keeps that distinction explicit. A seventh crate, `amiss-fixtures`, exists only for tests: it writes hostile Git bytes straight into test repositories so the same fixtures exist on every platform. The [`controller/`](https://github.com/HardMax71/amiss/tree/main/controller) crates sit outside that graph. They are unpublished, nothing above depends on them, and they 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 and synchronous evaluation 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](controller.md) defines the neutral record and retry rules. [Provider-verified controls](provider-controls.md) compares the concrete flows and links each provider's setup and trust boundary. Inside an engine run, the stages form a line: ```dot process digraph pipeline { rankdir = LR; node [shape = box, fontname = "Latin Modern, Georgia, serif", fontsize = 11]; edge [arrowsize = 0.7]; snap [label = "snapshots\nbase + candidate"]; disc [label = "discovery"]; parse [label = "parse +\nextract"]; res [label = "resolve"]; corr [label = "correlate"]; eval [label = "evaluate +\npolicy"]; rep [label = "report"]; snap -> disc -> parse -> res -> corr -> eval -> 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](security.md) 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. --- # Edge cases and divergences The edge cases in the suite are where the contract's exact wording gets earned. A sample: Paths are bytes, so `été.txt` resolves without any Unicode normalization, and two names that differ only in case are two different files even on a filesystem that would merge them. A directory link resolves the same way through a commit and through the staged index, which sounds obvious until you know the index stores no directory entries at all and containment has to be proved from sorted path prefixes. `guide.md/` with a trailing slash fails as a type mismatch while `guide.md` resolves right next to it. `%252F` in a link decodes once, to the literal text `%2F`, and never becomes a second path separator. Hostile input gets the same rules with more suspicion. A document path carrying ANSI color codes, a terminal bell, and a forged CI command prints harmlessly in the human output and survives byte-for-byte in the JSON. A tree entry named with bytes that are not UTF-8 is a scanned document whose path travels as hex, not a refusal and not a silent drop, while a name the path grammar itself rejects, a backslash or a dot segment, is an `UNREPRESENTABLE_PATH` refusal that disclosed those exact bytes. A five-thousand-byte path is a reported limit crossing carrying both numbers. A tracked file whose object is missing from the store refuses and names the document, instead of guessing about content it cannot see. The forge dialects pin the URL spellings the forges' own browsers emit and nothing looser. GitLab's legacy pre-separator form still redirects in a browser and is foreign here, as is `/-/raw/`; a GitLab project literally named `-` could never be told apart from the separator, and GitLab reserves the name anyway. Gitea's untyped `src//` 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 line-anchor grammars do not leak either: `#L10-20` selects lines only under the gitlab dialect, `#L10-L20` only under github and gitea, so the same fragment can resolve on one forge and remain unsupported on another. Single-line `#L10` is common to all three. The parser pin records its known differences instead of hiding them. Measured against the pinned grammar bundle and against GitHub's own rendering, exactly one difference affects link extraction: `[link[^1]](#)`, a footnote call inside a link label, which the pinned Rust parser does not turn into a link. A reference written that way goes unseen. That is under-reporting, the safer direction to fail, and it is written down in the corpus notes rather than waiting to be discovered. Two upstream test documents make the parser panic; the engine catches both as `PARSER_PANIC`, and both live in the corpus as regression tests. The [GFM](https://github.github.com/gfm/) spec text says `ftp://` should autolink where the pinned bundle and GitHub's renderer disagree; the bundle wins, and the corpus records why. One more, for flavor: the object store re-hashes everything with SHA-1 collision detection, and the suite proves that the public SHAttered and Shambles collision files cannot even be framed as Git objects without breaking the very property the detector checks. Reachable in code, unconstructible in practice, and tested as such. --- # What Amiss is not Amiss does not read your prose. A hash can prove that a file changed; it cannot prove that a sentence became false, or that anyone reviewed it. The investigation behind this tool spent a long time on designs that pretended otherwise. 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](https://github.com/fiberplane/drift), [Swimm](https://swimm.io), 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](provenance.md). 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 ten renderers call a heading](anchor-rules.md) 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](route-spellings.md), which reaches a file the tree already holds or nothing at all. That is the whole of what it knows about routes: a leading slash still names a site route it does not answer, and 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 wins wherever docs are authored inside its platform: its auto-sync can repair a renamed token without a human. Amiss never edits prose because a structural observation does not authorize a semantic rewrite. Swimm sees documents authored for its platform; 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](https://docs.python.org/3/library/doctest.html) 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](evidence.md) 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. --- # 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](https://lychee.cli.rs/) 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 ten 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 as written, so the list a checker needs comes out of a run that already happened: ```sh amiss check --repo . --base "$BASE" --candidate HEAD --format json | jq -r '.payload.observations[] | (.base, .candidate) | select(. != null) | .external_destination | select(. != null)' | sort -u | lychee - ``` 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: | | Amiss | lychee | | --- | --- | --- | | Checks external URLs | never fetches, lists them for you | yes | | Checks heading anchors | against ten pinned renderer rules | with `--include-fragments` | | Compares two snapshots | always | no | | Attributes a finding to the change | introduced, pre-existing, resolved | no | | Reports changed content under unchanged prose | yes, as advisory | no | | Checks the staged index before commit | `--index` | no | | Policy can loosen the gate | never, loosening is itself a finding | configuration is open | | Byte-identical reports with digests | yes | no | 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. None of them compares snapshots. 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](profiles.md). --- # 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](https://github.com/HardMax71/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](https://github.com/HardMax71/amiss/blob/26df8f76f84ee0e8bbee3f8c7a5ab49a44eaaadc/docs/repo-audit.md) contains the observed cases, and the [experiment index](https://github.com/HardMax71/amiss/blob/26df8f76f84ee0e8bbee3f8c7a5ab49a44eaaadc/docs/experiments/README.md) 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](provenance.md), and both explain why the scanner only ever reports what two trees say. --- # Provenance Amiss started life as a different tool. The original design was a review tracker with memory: a committed ledger of what had been checked, paragraphs trusted when edited, an `ok` command that recorded a human's acceptance, and a refresh job that kept the ledger current. Point it at a repository and it would tell you which paragraphs had gone stale against the code they cited, and remember your answer. The pre-implementation review killed that design, and the reasons survived a second, adversarial review. Identity rules that were safe for automatic observations were unsafe for recorded human attestations sharing the same storage. A committed ledger file conflicted between branches at rates that would have made merge queues the real product. Trusting a paragraph because it was edited blessed pages that were already wrong. 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](evidence.md). 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](https://github.com/HardMax71/amiss/tree/26df8f76f84ee0e8bbee3f8c7a5ab49a44eaaadc/docs). The machine-readable contracts, schemas and canonical examples ship in `spec/`, led by the [current report contract](https://github.com/HardMax71/amiss/blob/main/spec/scanner-report.schema.json). Where this book and the dossier disagree, the shipped code and its tests are the authority for what the tool does; the dossier remains the authority for why. --- # Development The toolchain version is pinned in `rust-toolchain.toml`, `unsafe` is forbidden in every crate, and the lint table denies panics, lossy casts, wildcard matches, and undocumented errors. Hooks run through [prek](https://github.com/j178/prek): formatting and the cheap checks on commit, then [Clippy](https://github.com/rust-lang/rust-clippy) with warnings denied, the full test suite, `cargo deny`, `cargo shear`, and two exact-count [similarity-rs](https://github.com/mizchi/similarity) twin-function ratchets on push. The tool compares functions within one file, so the first ratchet counts twins inside every file of both workspaces, and the second concatenates the deliberately parallel provider files, the three transports, the three lane-test harnesses, and the three service runtimes, so their cross-file twins stay counted as well. Each baseline is exact rather than a ceiling: a new twin fails as a regression, and a cleanup lowers the pinned number in the same change. A last push-stage hook runs [cargo-sweep](https://github.com/holmgr/cargo-sweep) over `target/`, dropping artifacts and incremental sessions older than five days; cargo never collects superseded builds, and this repository mints a fresh copy of every test binary on each lockfile or version change. The hook is a no-op where cargo-sweep is not installed. CI runs the same two hook stages, so a hook that passes locally passes remotely unless the hook table itself has a bug. What CI adds on top is the work that does not belong on a developer's machine: the fuzz packages, whose release builds and separate lockfiles cost minutes, and mutation, which costs ten of them for a change of any size. A push should not buy what a pull request already measures. 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](ci.md). ```sh cargo nextest run --workspace --locked cargo clippy --workspace --all-targets --locked -- -D warnings cargo test --manifest-path fuzz/Cargo.toml --locked --release cargo clippy --manifest-path fuzz/Cargo.toml --all-targets --locked -- -D warnings ``` The first pair checks every crate, engine and provider alike, from one lockfile. The second checks the scanner's standalone fuzz package, which keeps its own lockfile because coverage-guided runs need nightly. The trust boundary is a dependency boundary rather than a workspace boundary: HTTP, provider API, Git acquisition, credential, storage, and service-runtime dependencies belong to the unpublished crates under `controller/`, and `deny-engine.toml` drops those crates 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](provider-controls.md). Tests answer to a house rule called the teeth check: important tests are exercised against deliberately broken behavior before they are trusted. The [mutation workflow](https://github.com/HardMax71/amiss/blob/main/.github/workflows/mutants.yml) publishes a non-gating measurement of that property, in three sizes. Every pull request measures only the mutants the change itself reaches, which is seconds when the diff is documentation and minutes when it is engine code. A release pull request measures what the release ships, every change since the last tag, rather than the version bump standing in front of it. Both ask the whole workspace whether a mutant lives. 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. 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](https://commonmark.org), [GFM](https://github.github.com/gfm/), and [MDX](https://mdxjs.com) suites; the [corpus notes](https://github.com/HardMax71/amiss/blob/main/corpus/README.md) document every known difference. Scanner parsers that take untrusted bytes have targets under `fuzz/`. The [controller fuzz package](https://github.com/HardMax71/amiss/tree/main/controller/fuzz) 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](https://github.com/HardMax71/amiss/blob/main/.github/workflows/fuzz-long.yml). 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 dry-runs the publication. `cargo publish --dry-run --workspace --locked` packages each publishable crate, resolves its siblings through a temporary local registry, and builds every tarball, so a file dropped from a package or a path dependency missing a version fails on the pull request instead of halfway through an upload that cannot be taken back. 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 four engine binaries, 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](https://github.com/HardMax71/amiss/blob/main/.github/workflows/release-plz.yml) 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](https://codeql.github.com) covers both the Rust and the workflows. [Scorecard](https://scorecard.dev), secret scanning with push protection, and build provenance attestations on release binaries round it out. --- # 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](https://github.com/HardMax71/amiss/blob/main/CHANGELOG.md). Future work and its entry conditions live in the [Roadmap](roadmap.md). ## Supported surface | Area | Current contract | Implementation anchor | | --- | --- | --- | | Command | `amiss check` compares a base commit with either a candidate commit or the staged index, 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 two forms. | [CLI parser](https://github.com/HardMax71/amiss/blob/main/crates/amiss/src/invocation.rs) | | Repository access | The 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-git/src/repo.rs) | | Documents | Built-in discovery covers Markdown, GFM, MDX, six extensionless Markdown basenames, and two plain-advisory basenames. Repository policy may add paths without installing another parser. | [Classifier](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/document.rs) | | References | Relative repository paths and same-repository GitHub, GitLab, and Gitea-family URLs are resolved under their declared dialect. 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. Numeric line fragments select and compare an exact inclusive byte range, and a heading anchor resolves when any of ten 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. Unsupported shapes remain visible in the report, and an external destination is recorded where it is seen, never fetched. | [Resolver](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/resolve.rs) | | Policy | `.amiss/scanner-policy.json` may expand discovery and raise the disposition of missing targets, target-type mismatches, and invalid references. It cannot downgrade or suppress a finding. | [Policy application](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/policy.rs) | | Reports | Machine output uses the rolling pre-1.0 report envelope and payload contract. Exact findings remain the evidence surface; engine-grouped Fix, Check, and Existing feedback is its review projection. The compatibility marker remains `experimental`. | [Current schema](https://github.com/HardMax71/amiss/blob/main/spec/scanner-report.schema.json) | | GitHub convenience Action | A 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](https://github.com/HardMax71/amiss/blob/main/action.yml) and [runtime](https://github.com/HardMax71/amiss/blob/main/crates/amiss/action/runtime.yml) | | GitHub provider lane | A source-built App service authenticates pull-request events, refreshes exact state and strict App-bound rules, acquires exact SHA-1 objects, runs the sealed bootstrap, and publishes on GitHub's test-merge commit. GitHub.com and compatible GHES releases are supported. | [GitHub setup](provider-github.md) | | GitLab provider lane | A 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](provider-gitlab.md) | | Gitea and Forgejo provider lane | A 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-gitea.md) | | Provider service operation | Every service has an offline configuration check, separate liveness and readiness, ten fixed label-free counters, redacted lifecycle events, and graceful drain. The listener and its operator endpoints remain private. | [Service operation](provider-controls.md#service-operation) | Repository form is deliberately closed too. The reader accepts a primary non-bare checkout whose `.git` entry is a real directory. Bare repositories and linked worktrees represented by a `.git` file are unavailable, and alternate object stores are not consulted. The [repository boundary](https://github.com/HardMax71/amiss/blob/main/crates/amiss-git/src/repo.rs) and its [boundary tests](https://github.com/HardMax71/amiss/blob/main/crates/amiss-git/tests/boundary.rs) 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, code symbols, live URLs, and references to other repositories are not validated under those systems' semantics. Their visible boundary behavior is described in [Discovery](discovery.md) and [Resolution](resolution.md). ## 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 controls as absent and has no target-ref option; its repository and candidate-ref fields remain caller assertions. The `forge` field selects a URL dialect, not an authenticated provider. Compare the [request schemas](https://github.com/HardMax71/amiss/blob/main/spec/scanner-evaluation-request.schema.json), [strict parsers](https://github.com/HardMax71/amiss/blob/main/crates/amiss-wire/src/requests.rs), [pipeline shell](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/src/pipeline.rs), and [CLI wiring](https://github.com/HardMax71/amiss/blob/main/crates/amiss/src/main.rs). `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. 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](https://github.com/HardMax71/amiss/blob/main/crates/amiss-bootstrap/src/main.rs), [constraint producer](https://github.com/HardMax71/amiss/blob/main/controller/constraint/src/main.rs), [release assembly](https://github.com/HardMax71/amiss/blob/main/.github/workflows/release.yml), and [Action execution](https://github.com/HardMax71/amiss/blob/main/crates/amiss/action/runtime.yml). The unpublished crates under [`controller/`](https://github.com/HardMax71/amiss/tree/main/controller) define the provider-neutral identities, bounded ingress, rotating verifier keys, durable raw inbox, delivery record, 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](controller.md) records the cross-process ownership, heartbeat, replay, and exact-publication retry rules. 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](provider-controls.md) compares the supported lanes and links their exact setup, retry limits, and trust boundaries. All three service binaries share an [offline configuration check](provider-controls.md#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](provider-controls.md#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 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](controls.md) and [The report](report.md) 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](profiles.md) and resource ceilings in [Limits and refusals](limits.md) come from the Rust constants through a test, so changing a constant without the book fails CI. The same [documentation contract test](https://github.com/HardMax71/amiss/blob/main/crates/amiss/tests/documentation_contracts.rs) 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 strict JSON reader, and its canonical bytes clear the [wrapper acceptance law](https://github.com/HardMax71/amiss/blob/main/crates/amiss-bootstrap/tests/acceptance.rs) end to end. The commit and staged-index identity preimages reproduce the production digest chain in the [identity golden test](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/tests/identity.rs). The published semantic corpora drive their live code paths: [frontmatter vectors](https://github.com/HardMax71/amiss/blob/main/spec/examples/frontmatter-vectors.json) through the [recognizer](https://github.com/HardMax71/amiss/blob/main/crates/amiss-md/tests/frontmatter.rs), [correlation vectors](https://github.com/HardMax71/amiss/blob/main/spec/examples/correlation-intent-vectors.json) through the [intent projection](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/tests/correlation_vectors.rs), and [governed-definition vectors](https://github.com/HardMax71/amiss/blob/main/spec/examples/governed-definition-vectors.json) through [report construction](https://github.com/HardMax71/amiss/blob/main/crates/amiss-scan/tests/governed.rs). 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. --- # 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 ten renderers call a heading](anchor-rules.md), 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. | Repository | Range | References | Missing | Advisory | Doc lines | Historical density | Rejection class | | --- | --- | ---: | ---: | ---: | ---: | ---: | --- | | helix | `5cda70e86637..f6f3eb1fe4a7` | 3,249 | 1 | 47 | 2,166 | 2.2 | none | | ripgrep | `a6e0be3c909c..227381db0ee8` | 766 | 0 | 6 | 214 | 2.8 | none | | just | `2fd820433b02..e19eb9c379bc` | 3,101 | 0 | 1 | 9 | 11.1 | none | | mdBook | `2ea30c00f006..69287f26827e` | 1,206 | 36 | 35 | 0 | undefined | test fixtures | | starship | `fca92d8dcbd5..3c3aaf4f7ed2` | 7,508 | 242 | 844 | 84,485 | 1.0 | clean URLs | | ruff | `0177a7e0d2c4..5055442b5875` | 5,244 | 102 | 102 | 1,146 | 8.9 | generated targets | | bat | `979ba22628bc..78951393e29b` | 451 | 12 | 27 | 214 | 12.6 | none | | fd | `7027d45303b4..1bfeea237a48` | 96 | 0 | 1 | 79 | 1.3 | none | | hyperfine | `975fe108c4ee..f12f3d9f86f3` | 48 | 0 | 1 | 37 | 2.7 | none | | alacritty | `a0be6eb8240c..852e971cddfa` | 87 | 1 | 5 | 65 | 7.7 | none | 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](https://github.com/helix-editor/helix/pull/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 behind the declared-generated-targets candidate on the [roadmap](roadmap.md). 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 ten renderers call a heading](anchor-rules.md), 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 the [roadmap](roadmap.md) argues from moved. | Repository | Head | References | Missing | Anchor | Other spelling | Absent | | --- | --- | ---: | ---: | ---: | ---: | ---: | | helix | `079a789e8cb0` | 3,249 | 10 | 9 | 0 | 1 | | ripgrep | `f9c05a949d1a` | 766 | 0 | 0 | 0 | 0 | | just | `af06bc49df4d` | 3,221 | 0 | 0 | 0 | 0 | | mdBook | `4f8c9460977e` | 1,206 | 37 | 1 | 6 | 30 | | starship | `eebb9a3c7ddc` | 7,509 | 344 | 103 | 241 | 0 | | ruff | `a5cdc6d5813b` | 5,289 | 104 | 2 | 0 | 102 | | bat | `78951393e29b` | 451 | 19 | 7 | 0 | 12 | | fd | `ca51233d277e` | 96 | 1 | 1 | 0 | 0 | | hyperfine | `f12f3d9f86f3` | 48 | 0 | 0 | 0 | 0 | | alacritty | `852e971cddfa` | 87 | 1 | 0 | 0 | 1 | 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](route-spellings.md). 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](discovery.md) 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](route-spellings.md), 30 left with the fixture trees [discovery](discovery.md) 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 `

just

`, github.com anchors that, and the rule table did not. It got a [pinned harvest and a fix](https://github.com/HardMax71/amiss/pull/140), which is why just reads zero above. ## 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. --- # 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. | Provider | Version | Control | Provider evidence | Gate commit | | --- | --- | --- | --- | --- | | GitHub | github.com | ruleset active | Check Run `success`, `conclusion: pass` | `6dbc7eb8c17b` | | GitHub | github.com | ruleset enforcement disabled | Check Run `failure`, `unavailable / authorization-revoked` | `2d584f289f50` | | Gitea | 1.27.0 | protection rule intact | review `APPROVED`, `conclusion: pass` | `4cf4fd91e3e2` | | Gitea | 1.27.0 | direct push re-enabled | review `REQUEST_CHANGES`, `unavailable / authorization-revoked` | `d9496a77e2f5` | | Forgejo | 16.0.1 | protection rule intact | review `APPROVED`, `conclusion: pass` | `ca697bd509d9` | | Forgejo | 16.0.1 | `apply_to_admins: false` | review `REQUEST_CHANGES`, `unavailable / authorization-revoked` | `4fa69ed7d4c5` | 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](https://github.com/HardMax71/amiss/pull/131). Both families blocked the drift, refused the merge, and approved the correction. GitLab has no row. The lane's floor is 19.3 with Ultimate and 19.2.0 was the newest release on the date above, so no supported instance existed to run. A live 19.2.0-ee confirmed the shapes the adapter reads and confirmed that the floor refuses the instance, which is a version check rather than a lane run. ## 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. --- # Roadmap This page tracks the work ahead: what is being done now, what could enter the roadmap later, and what stays research. It is not release notes or a promise that every candidate will ship. The factual boundary of the current product is in [Project status](status.md), the exit evidence for phases already closed is in [Completed phases](completed-phases.md), and version history is in the [changelog](https://github.com/HardMax71/amiss/blob/main/CHANGELOG.md). ## Now: retain live provider evidence The provider code chapter is closed. What remains cannot be produced by a local fixture: it requires accounts and protected test projects on the providers themselves. GitHub, Gitea, and Forgejo are done. [Retained provider runs](provider-evidence.md) holds a positive and a revoked-control run for each, against github.com, Gitea 1.27.0, and Forgejo 16.0.1. Running them found four defects that every fixture had agreed with, so the lanes themselves changed on the way. - Retain positive and revoked-control runs from a GitLab project. Record the provider version, controller commit, and provider evidence. The lane's floor is 19.3 with Ultimate and 19.2.0 is the newest release, so this waits on GitLab. Local HTTP fixtures remain regression tests, not live-provider evidence. ## Reference-coverage candidates Candidates, not scheduled milestones. Each enters the roadmap only when its entry condition is met. Three have left this list by meeting theirs. The slugging rules of ten renderers are pinned against the renderers themselves, so an anchor no rule publishes is now an ordinary missing target. Router spellings are pinned the same way, harvested from three routers rather than transcribed, so a destination the tree does not hold is asked again under the spellings a router serves: it moved 247 of the 516 missing references across the ten trees, starship's 241 and mdBook's 6, and moved nothing else. Both are described in [Resolution](resolution.md). - reStructuredText or AsciiDoc. Entry condition: a pinned grammar, a conformance corpus, extraction goldens, resource accounting, and honest opaque regions, the same set the Markdown adapters carry. ## Research, not committed work Typed snippet, value, 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](provenance.md). 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. The permanent boundaries stay in [What Amiss is not](non-goals.md): 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. --- # Completed phases Four phases are closed. Each claim they made has its own page here: what the claim is, what it defends against, how it is held, and where it landed. These are dated exit records rather than live documentation, so a page states what was true when the phase closed and links the code that has to stay true for it to hold. Current work is in the [Roadmap](roadmap.md), the factual boundary of the product is in [Project status](status.md), and version history is in the [changelog](https://github.com/HardMax71/amiss/blob/main/CHANGELOG.md). ## 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](completed/generated-contract-tables.md) - [Embedded code cannot buy unbounded parse time](completed/bounded-embedded-code.md) - [Ten public repositories were scanned and the counts kept](completed/shadow-scan-ledger.md) - [A false missing target is a bug, not a statistic](completed/false-missing-is-a-bug.md) - [Review feedback is grouped, ordered, and bounded](completed/grouped-review-feedback.md) - [The self-scan runs the event shapes it claims to support](completed/self-scan-event-coverage.md) - [Mutation and fuzz runs are installed with recorded baselines](completed/mutation-and-fuzz-baselines.md) ## 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. - [Claim, lease, result, and completion are one contract](completed/delivery-record-contract.md) - [Every accepted delivery carries a replay lifetime](completed/replay-lifetime.md) - [The record is ordinary files, not a database](completed/file-ledger-implementation.md) - [Lock growth is fixed and admission does not scan](completed/file-ledger-locks.md) - [A row is one bounded state file and, briefly, a report](completed/bounded-row-files.md) - [Cleanup removes only what is safe to forget](completed/record-cleanup.md) ## 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. - [One evaluation contract, not one per provider](completed/rolling-evaluation-contract.md) - [The bootstrap takes canonical documents and nothing else](completed/sealed-bootstrap-inputs.md) - [The controller ships as source, not as a crate](completed/unpublished-controller-workspace.md) - [Authenticate first, save the raw bytes, then acknowledge](completed/authenticated-ingress.md) - [The record and the runner share one lease](completed/shared-lease-contract.md) - [The runner seals the job it supervises](completed/sealed-runner.md) - [The GitHub lane runs one repository end to end](completed/github-lane.md) - [The GitHub source accepts four events and binds them](completed/github-event-source.md) - [Objects are fetched by exact name under fixed limits](completed/exact-object-acquisition.md) - [The verdict lands on the commit GitHub actually merges](completed/github-publication.md) - [The GitLab lane runs as a policy job on the merge train](completed/gitlab-lane.md) - [The GitLab gate refuses anything but the exact saved pass](completed/gitlab-merge-train-gate.md) - [The Gitea family lane publishes through a dedicated reviewer](completed/gitea-lane.md) - [The Gitea family gate is checked, not assumed](completed/gitea-reviewer-gate.md) - [The lanes are tested through, and against, themselves](completed/lane-test-coverage.md) - [Provider evidence lives in the provider, not in the report](completed/provider-owned-evidence.md) ## Provider operations Closed July 2026. A lane that cannot be deployed, watched, or restarted without losing work is not finished, whatever its verdicts say. - [Every provider binary can check its configuration offline](completed/offline-configuration-check.md) - [Liveness and readiness answer different questions](completed/liveness-and-readiness.md) - [Ten counters, no labels, no cardinality surprise](completed/fixed-metric-set.md) - [Shutdown finishes the work it already accepted](completed/graceful-drain.md) - [Hostile provider input is tested without provider accounts](completed/account-free-robustness.md)