Introduction
Documentation drifts: code moves and the prose that points at it doesn’t. Tests catch the code half. The prose half sails through review, since a paragraph that didn’t change draws no eye. Amiss is the gate for that half. It compares two exact states of a repository, extracts the references its grammar supports from every document it discovers, resolves each against the tree, and reports what broke, what changed under unchanged prose, and what it could not check. It never reads meaning: it can’t tell you whether a sentence is true, and it doesn’t try.
The supported boundary
Two closed sets draw the line: which files count as documents, and which references count at all.
The document set is fixed by name. Markdown and MDX by extension, AsciiDoc and
reStructuredText the same way, six bare basenames like README, and two advisory files,
.cursorrules and llms.txt, whose adapter extracts no references. A notebook or Org
file is discovered, counted as unsupported, and never read. Everything else is a possible
reference target, not a document. Discovery has the exact rows, and
repository policy can bind one of the five built-in adapters to any path it names.
“Supported explicit reference” is the second line, and it’s hard. Bare path-like prose is never 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. 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. Trusted semantic evidence can additionally map an exact candidate site route, anchor, or fragment-aware terminal redirect to the scanned source that produced the published page. Every such answer still reaches a file the tree already holds. Resolution describes the boundary rows, and Project status links the classifier and resolver that draw them.
The four questions
A run takes a base and a candidate: two full commit IDs, or a commit and the staged index
when you pass --index. Amiss answers four questions about them, and nothing else:
- Does every supported explicit reference still point at something in the candidate tree?
- Did the selected content or file mode of a referenced target change between base and candidate?
- Did the paragraph holding the reference change too, stay exactly the same, disappear, or become impossible to match up without guessing?
- What did the scan actually see: which documents it read, skipped, could not parse, or found unreachable?
The fourth question matters as much as the first three. A checker that silently skips what it can’t handle is worse than no checker, since its green claims more than it checked. So everything Amiss cannot read or follow becomes a visible row in the report, and a document it cannot decode at all fails the run instead of dropping out of it.
What a run never does
The scanner keeps no state. No baseline file, no cache, no database, nothing committed to your repository. Repository policy can expand discovery and raise three finding kinds; it can never lower a disposition or hide a finding. Provenance tells how the project arrived at that stance, and Controls and policy draws the exact boundary.
Each promise below is pinned by tests:
- A check never writes. The
no-write suite
snapshots every byte under the repository root,
.gitincluded, runs five check invocations over it, and proves the snapshot unchanged; on Unix it also scans a fully read-only repository. The two verbs that do write,amiss fixandamiss adopt, touch exactly the paths their output names and nothing else. - It never runs repository code and never calls the
gitcommand. It reads Git’s objects, packs, and index directly through the repository reader. - It never follows symlinks while reading. Every file opens relative to a held directory
handle with following disabled, and a link at the repository root, at
.git, or anywhere along an object’s path is refused. The refusal is never confused with a missing file. - It never touches the network. The dependency gate bans the socket stack from the engine’s graph, so a missing object is a typed refusal, not a fetch.
- The same repository, commits, and engine binary give the same report bytes, run after run, even across a repacked object store.
- Resource ceilings have names and published values, all forty listed in Limits and refusals. A measured crossing produces a typed error naming the limit and the observed lower bound. Parser CPU spent before node accounting is a disclosed limitation in Security model, not covered by a stronger “nothing can hang” promise.
The rest of this book walks those promises in the order a run does: what counts as input, what gets scanned, how references resolve, what the report says, and where the boundaries sit. Just want to run it? Start with Invocation.
Licensing
Amiss source code and documentation ship under the Functional Source License 1.1, ALv2 Future License. The repository’s third-party notices attribute the parser evidence, documentation assets, and fonts. Released Action trees carry the project license and a plain-text license bundle built from the locked dependency graph. The Latin Modern webfonts this book serves are covered by the GUST Font License, and the notices mdBook embeds in generated JavaScript and SVG assets stay 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/:
--- a/src/retry.rs
+++ b/src/backoff.rs
@@ -1 +1 @@
-pub const MAX_ATTEMPTS: u32 = 3;
+pub const MAX_ATTEMPTS: u32 = 5;
The operations page keeps reading:
Retries are capped at three attempts; the limit lives in [`src/retry.rs`](../src/retry.rs).
The paragraph didn’t change, so nothing in review looks at it. Amiss does: the link’s
target is gone from the candidate tree, which blocks under enforce. Change the constant
in place instead and you get the warn: changed bytes under an unchanged paragraph. Neither
finding says “three” is now a lie. That call belongs to whoever reads the finding.
The audit behind this tool went through one repository that took documentation seriously: golden files, executable CLI examples, a link checker, roughly a dozen hand-built defenses. It still held seven live drift classes. The architecture page counted ten workflows against 22 in the tree and named one that never existed. The CLI reference documented a three-value exit-code contract while the code used four. Railroad diagrams regenerated on every docs build, faithfully, from a stale copy of the grammar embedded in the generator script. The fresh output proved one thing: the stale input still compiled. Executable examples all stayed green, since examples protect the paths they execute and nothing else.
Checkers that run on demand inherit the failure they exist to catch, since the person who
forgot to update the page also forgot to run the checker. Tools that rewrite prose to
match the code make a different mistake: deciding what the code means is the one judgment
a machine should refuse. So Amiss splits the work. The rewrites the engine can prove ship
as fixes amiss fix applies byte for byte: a path off only by case from one tracked
spelling, an anchor off by case or separator style, a claim expecting a line’s old text.
Everything that needs judgment goes to someone who can be held to account: a person, or a
coding agent reading the finding’s own description.
Every run compares two exact snapshots. Under enforce, a reference that stops resolving
blocks the change that broke it. A referenced file that changed under an unchanged
paragraph warns; Correlation and impact draws that boundary precisely.
The code moving is a reason to reread the prose, not proof the prose is wrong. What the
tool cannot see, it declares. And repository policy can raise severity but never lower it,
so the gate survives the kind of change it exists to catch.
The full taxonomy of what a scan establishes is in Profiles and findings. What Amiss deliberately does not attempt, starting with reading your prose, is in What Amiss is not.
Invocation
Install from crates.io, or build from source:
cargo install amiss
Every release also carries the engine and the external prober
prebuilt for Linux on x86_64 and arm64, both macOS architectures, and Windows x86_64, with a
SHA256SUMS file and the sigstore bundle that attests it.
gh attestation verify <binary> --repo HardMax71/amiss matches a downloaded binary against the
build that produced it.
The public command line is closed: the grammar below is everything, and anything else
exits 2 as an invalid invocation. The verb comes first; after it the options come in any
order, each at most once. Standalone --help prints this whole grammar on stdout. A refused
human invocation prints the violated contracts and then the same grammar on stderr, so the
binary teaches its own command line on either path. The one exception is a malformed --format
selection, which prints a single amiss: invalid invocation line, since the output channel itself
was never agreed. The copy below is checked against the binary’s in CI.
amiss check --repo <path> --object-format <sha1|sha256>
--base <full-oid> (--candidate <full-oid> | --index)
[--repository <host>/<owner>/<name>
--ref refs/heads/<name>
--default-branch-ref refs/heads/<name>
[--forge <github|gitlab|gitea|bitbucket-cloud|bitbucket-data-center>]]
--profile <observe|enforce-introduced|enforce>
[--semantic-template <path>]
[--explain-scope] [--format <human|json|sarif|codequality>]
amiss fix --repo <path> --object-format <sha1|sha256>
--base <full-oid> --index
[--repository <host>/<owner>/<name>
--ref refs/heads/<name>
--default-branch-ref refs/heads/<name>
[--forge <github|gitlab|gitea|bitbucket-cloud|bitbucket-data-center>]]
--profile <observe|enforce-introduced|enforce>
amiss claim --repo <path> --path <repo-path> --line <n> --name <name>
amiss policy-include --path <repo-path> --suffix <suffix> --adapter <adapter>
[--repo <path> --object-format <sha1|sha256> --index]
amiss record-set --evidence <path>
amiss adopt --repo <path> --object-format <sha1|sha256>
--base <full-oid> --candidate <full-oid>
--repository <host>/<owner>/<name>
--ref refs/heads/<name>
--default-branch-ref refs/heads/<name>
[--forge <github|gitlab|gitea|bitbucket-cloud|bitbucket-data-center>]
--floor-digest sha256:<64-hex> --debt-owner <name>
--debt-reason <text> --created-at <utc-instant>
--expires-at <utc-instant> --debt-output <path>
amiss external-plan --report <path> [--format <human|json>]
amiss external-assess --plan <path> --evidence <path> [--format <human|json>]
amiss render --report <path>
(--format human [--full] | --format <sarif|codequality|junit>)
amiss refs --report <path>
(--target <repo-path> | --target-bytes-hex <lower-hex>)
[--format <human|json>]
amiss --help
amiss --version
The table gives each flag in one line. The paragraphs after it carry the exact semantics; trust them when the short form reads ambiguous.
| Flag | Value | Role |
|---|---|---|
--repo | path | the repository checkout to read; optional only for a policy-include row without an index preview |
--object-format | sha1 or sha256 | the repository’s object format; paired with --repo and --index in a policy-include preview |
--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, or selects it for a policy-include preview |
--repository | <host>/<owner>/<name>; owner and name lowercase | unverified identity claim for same-repository URLs |
--ref | refs/heads/<name> | the candidate branch this tree belongs to; in the adopt form, also the ref the minted debt binds to |
--default-branch-ref | refs/heads/<name> | which branch counts as default when resolving URLs |
--forge | github, gitlab, gitea, bitbucket-cloud, or bitbucket-data-center | URL dialect; an explicit flag beats the host table |
--profile | observe, enforce-introduced, or enforce | report only, block introduced findings while carrying the backlog, or let every blocking finding gate; see Profiles and findings |
--semantic-template | path | one strict, bounded, candidate-free semantic template for check; the scanner binds it to the exact commit or staged-index identity and the run remains self-asserted |
--explain-scope | none | adds deterministic scope lines to human output |
--full | none | prints every feedback item when replaying a report as human output; foreign to every other form and format |
--format | human, json, sarif, codequality, or render-only junit | grouped human items, the exact report in The report, or one of its CI projections; human output is bounded unless replayed with --full |
--path | repo-relative path | the file an authored claim pins, or the exact root of an authored suffix selector |
--line | positive line number | the line the claim expects, one-based |
--name | ASCII claim name, 1 to 120 bytes | the amiss: label; starts with a letter or digit, then letters, digits, ., _, - |
--suffix | dot-prefixed UTF-8 suffix | the exact 2–64 byte tail of an authored tree selector; no slash, backslash, or NUL; glob metacharacters stay literal and no normalization occurs |
--adapter | asciidoc, markdown, mdx, plain-advisory, or rst | the built-in grammar an authored selector binds to matching paths |
--floor-digest | sha256: and 64 hex | the organization floor the minted debt snapshot binds to |
--debt-owner | text | the item owner the floor must authorize |
--debt-reason | text | why the debt is being recorded |
--created-at | UTC instant | the snapshot’s and items’ creation instant |
--expires-at | UTC instant | when the items expire; must be after --created-at |
--debt-output | path | where the minted snapshot is written; must not exist |
--report | path | the report file the plan, render, or refs form reads; foreign to every other form |
--plan | path | the plan file the assessment form judges; foreign to every other form |
--evidence | path | the external observations external-assess judges, or the normalized specialist input record-set turns into a semantic template; foreign to every other form |
--target | repo-relative path | the text path whose candidate references refs returns |
--target-bytes-hex | lowercase even-length hex | the raw-byte path whose candidate references refs returns; exclusive with --target |
--help | none | prints the canonical closed grammar; stands alone, with no verb or other flag |
--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: lowercase hex, forty characters for
sha1, sixty-four for sha256. Branch names, short forms, and two equal IDs are refused. Amiss
evaluates exactly the trees you name and resolves nothing for you. --index swaps the
candidate for the staged state, including entries marked
skip-worktree.
The identity group is a claim, not a login. --repository github.com/acme/widgets tells
the resolver which same-repository URLs to read as this repository; nothing verifies you
own it, so the spelling is strict. The host matches your documents’ URLs byte for byte and
is never case-folded. Owner and name must be lowercase ASCII, so a workflow passing
github.repository lowercases it first. Owner segments may nest, the GitLab group form;
an effective github, gitea, bitbucket-cloud, or bitbucket-data-center dialect refuses a nested owner it could never
match. A wrong
spelling is refused, never rewritten. Without the identity group every absolute forge URL
stays external, and a human run with a nonzero external count says so beside its totals
rather than degrading silently.
--ref names the candidate branch for URL resolution only: no protected target branch,
no --target-ref, and the report’s target stays null. No spelling of these flags turns a
CLI run into a provider-authenticated one. A URL naming the declared default branch while
another candidate is under test is recognized and reported as unsupported-version-scope,
not resolved. Full lowercase commit IDs must match --object-format and resolve only through that
exact commit’s locally available objects. A fully walked tree may prove absence; an unavailable
commit, tree, or target retains its exact ID and contained path as unsupported version evidence. A
branch whose spelling is also a full ID is refused as ambiguous. Without the identity group, forge
links stay external URLs and the report says so.
--forge names the URL dialect the resolver applies and accepts exactly five values.
github covers GitHub and GitHub Enterprise, gitlab the /-/blob/ separator form,
gitea the form Gitea, Forgejo, and Codeberg share, and bitbucket-cloud the Cloud
/src/<commitish>/<path> form. bitbucket-data-center covers project and personal
browse routes whose revision is carried by the query; it is always explicit because Data Center
has no canonical host. Without the flag, github.com, gitlab.com, codeberg.org, and bitbucket.org
select their own dialects; an identity on any other host is
refused as INVALID_EVENT until the flag names its dialect, since accepting it would
silently leave every same-repository link external. An explicit flag beats the table;
that’s how a self-hosted instance gets its grammar. Recognizing a dialect authenticates
nothing about how the run was invoked.
--semantic-template gives check one candidate-independent semantic producer result, such as a
complete record-set@1 inventory. The file follows the
semantic-template schema,
is capped at 16 MiB, and cannot name a candidate or source report. The scanner waits until it has
resolved the exact commit tree or pinned staged-index projection, binds the template to that
candidate identity, and then applies the same compiled consumers and limits as sealed evidence.
Malformed, oversized, or consumer-invalid input ends the run incomplete. The path is admitted only
by check: fix, adopt, authoring, and report-only commands refuse it. Because the caller chose
the file, the report still says sandbox.assurance: self-asserted; this flag never enters the
provider-authenticated controls lane.
--format json prints the exact report in The report, one line plus a
trailing newline. sarif and codequality project the same report for code-scanning
uploads and GitLab merge-request widgets. A refused invocation still emits a refusal
envelope under json and sarif, and an empty array under codequality, so a consumer never
parses half a document. JUnit is deliberately absent from check: it can only reopen a
validated report through render.
human is the default. It prints a status header, one error row per retained analysis
error, at most ten grouped Fix and Check items naming only a target and an affected-place
count with an overflow line when more exist, then at most ten Existing items with their
own overflow line, one fixed note sentence per error code using the wording from
Limits and refusals, and three totals lines. Existing items are the
pre-existing backlog at warn or fail, and the backlog keeps its own window, so introduced
volume cannot push it off the terminal. The full findings stay in JSON. --explain-scope adds six scope lines to that human output, five
fixed and one naming this run’s counts, and changes nothing in JSON, behavior pinned by the
CLI tests.
amiss render --report <path> --format <human|sarif|codequality|junit> reopens one JSON report
and emits an alternate projection without reading the repository or evaluating it again. It
accepts only the active report envelope, supported wire compatibility, matching payload digest,
and a consistent recorded result. A successful projection exits with that recorded 0, 1, or 2;
a closed stdout does not change it. Invalid, unreadable, or oversized report input exits 2 without
output. JSON is not an admitted projection because the report file is already canonical JSON;
requesting it is a grammar refusal and may emit the standard incomplete JSON refusal envelope.
Human replay additionally accepts --full, which prints every Fix, Check, and Existing item in
the report’s canonical order without overflow lines. It changes no facts, totals, notes, or exit
class; the ordinary human projection keeps the two independent ten-item windows.
JUnit emits one deterministic suite. Each finding is one case named by its kind and stable
finding key: effective fail is a failure, while warn and record remain passing cases whose
disposition and description ride in system-out. Each retained analysis error is an error case.
A report with no rows gets one passing report case so CI dashboards retain the artifact. File
locations ride only when their exact text can round-trip through an XML 1.0 attribute, and every
duration is zero because the canonical report records no timing. The XML cannot change the recorded
verdict; GitLab likewise treats a JUnit artifact
as display data rather than the job result.
Exit codes are three classes, not detail. 0 means the run completed and nothing blocks. 1
means a finding blocks. 2 means nothing trustworthy could be produced. A consumer that
closes the pipe early, head among them, ends the printing and not the verdict.
amiss fix repairs what the check proves, over the staged state only. It runs the same
evaluation as check --index, takes every finding whose fix is not null, and rewrites
exactly those byte spans in the working tree. Nothing is applied on faith. The staged
index is pinned before the evaluation and verified unchanged before any write. A document
is repaired only while its working-tree bytes still equal the staged bytes the fixes were
computed against, and one already holding the repaired bytes counts as already fixed. A
document is refused whole when it is missing from the index, a symlink, unreadable,
escaping the worktree, differing from its staged bytes, carrying overlapping or
out-of-range spans, or failing the write; each refusal row names its reason. Output is one
row per document and a summary line, so --format, --explain-scope, and --candidate
are refused rather than ignored. Exit 0 means every carried fix was applied or already
present. Exit 1 means a document refused, or the staged index moved mid-run with nothing
applied. Exit 2 means the evaluation could not be trusted or the staged index could not
be read; either way nothing was touched. Restage and rerun amiss check to see the
repaired state judged.
amiss claim authors a value claim and reads no git at all. Give it a
repo-relative path and a one-based line; it reads that line from the working tree and
prints one ready definition to stdout, nothing else, so the output pastes or pipes
straight into a document. It proves before printing: the candidate definition is run back
through the markdown extractor and the claim grammar, double-quoted first and
single-quoted when that round trip fails. A line neither spelling can carry, an HTML
entity among the causes, is refused rather than printed broken. Exit 0 prints the
definition. Exit 1 refuses the file or the line: unreadable, past the end, not UTF-8, or
numbered beyond the platform. Exit 2 is an invalid invocation, which is also where a
--name outside its grammar or a --path carrying reserved bytes lands.
amiss policy-include authors the one exact root-and-suffix selector from
repository policy. Without the optional group it prints one canonical JSON include
row to stdout, ready to insert into the policy’s sorted document_includes array. The row is built
and accepted by ScannerPolicy before it is printed, so the helper has no parallel path, suffix,
adapter, or canonicalization grammar. It never reads or edits an existing policy and therefore
cannot merge, replace, broaden, or reorder repository controls.
Adding --repo, --object-format, and --index together switches the output to one canonical
JSON array of the current stage-zero paths that the selector matches, in raw Git path order. The
preview uses the scanner’s production suffix matcher, its repository/index reader and ceilings,
and an end-of-read index identity check. A path that is not UTF-8 uses the report’s existing
{"bytes_hex":"..."} form. This previews selection only: a built-in document classification still
wins its adapter, and a later scan can still reject an unavailable object or unsupported entry
kind. The three preview flags are one group; partial groups and every unrelated option are invalid
invocations. Exit 0 wrote the row or complete preview, exit 1 means the repository, index, or output
was unavailable, and exit 2 means the closed invocation or selector grammar was invalid.
amiss record-set turns one strict
record-set input
into the candidate-free semantic template accepted by amiss check. The
input names the specialist producer, its producer-defined context and input digests, completeness,
one stable set name, and key-sorted unique rows. Amiss applies the scanner’s exact key/value bounds,
fixes the semantic producer contract to record-set@1, runs the result through the checked template
writer, and prints canonical JSON plus one LF. It does not run the specialist, inspect a repository,
recompute or authenticate the specialist-defined digests, or elevate the output above
self-asserted evidence. Exit 0 wrote the template, exit 1 means the input or output was unavailable
or invalid, and exit 2 means the closed invocation was invalid.
amiss adopt onboards a repository that already has drift. It runs the evaluation under
enforce, accepting no --profile, and mints a debt snapshot from every
blocking finding of the two debt-eligible kinds, explicit-target-missing and
explicit-target-type-mismatch. Gate new drift today; work the recorded backlog off on
its own clock. The engine supplies each item’s key and accepted fact from the evaluation.
The flags supply what it cannot know: the floor digest the snapshot binds to, the owner
that floor must authorize, the reason, both instants in the wire’s own clock grammar with
creation strictly before expiry, and the output path, which must not exist. Adoption
records a committed tree, so --index is refused and the identity triple is required.
--ref does double duty here: beyond URL resolution, its exact string becomes the
snapshot’s ref binding. Spell it as the branch the consuming lanes enforce, since a
snapshot bound to another ref stays out of scope there; the check form is untouched by
that reuse. Consumption is narrower than minting: only the sealed bootstrap that the
provider lanes operate feeds a snapshot back to the engine, while
the public check form and the convenience Action supply every control as absent and
never read one. On a repository without a lane the minted file waits, and the working
ramp for a standing backlog is enforce-introduced. The minted file is
written only after the engine’s own reader accepts its
bytes, by exclusive creation. The summary line counts what was recorded, what blocked but
is not debt-eligible, and what was eligible but missing facts. Exit 0 recorded the
snapshot. Exit 1 means the output path already exists or the write failed, any partial
file removed. Exit 2 means nothing trustworthy could be recorded: the evaluation failed,
the report carried no candidate tree, or the minted bytes failed the engine’s own reader.
amiss external-plan derives the external plan from a report file a
check --format json run already wrote. It opens no repository and touches no network:
it verifies the report’s own payload digest, refuses an incomplete report, and projects
the delegated-evidence delta the report already carries. --format takes human or json here;
SARIF, Code Quality, and JUnit remain report projections and are refused here. Exit 0 wrote the plan. Exit 2 means the input
could not be trusted: unreadable, larger than a scanner report can be, not the scanner’s
strict JSON, not a report envelope, digest mismatch, incomplete, or carrying a malformed
eligible occurrence.
amiss external-assess judges an external plan against one
producer’s observations, writing the external assessment
offline under the engine’s fixed policy. It verifies the plan’s
digest and the evidence’s binding to that exact plan; evidence naming a destination the
plan did not introduce, repeating one, or binding another plan refuses the whole run.
--format takes human or json. Exit 0 wrote the assessment, refuted rows included,
since the artifact is advisory data. Exit 2 means an input could not be trusted.
amiss refs asks a complete validated report which candidate occurrences refer to one
repository path. It opens no repository and does not reinterpret prose: an occurrence matches
when its normalized repository intent, resolved or unresolved path, resolved target, or known
version-scoped path is the exact target. Correlation alternatives are included, so ambiguous
pairing cannot hide a candidate occurrence. Human output names every document, source position,
construct, resolution, and observation ID. JSON output is an array of the unchanged candidate
Occurrence objects already defined by the report schema, not a new report or wire envelope.
--target-bytes-hex makes raw non-UTF-8 Git paths queryable on every platform. A valid empty
answer exits 0 even when the source report recorded blocking findings; incomplete, malformed,
unreadable, oversized, or digest-mismatched reports exit 2. Querying writes no state and changes
no recorded verdict.
amiss --help projects the exact Rust-owned grammar above, followed by one newline, and exits 0.
It opens no repository and accepts no verb or second token. A combined, duplicated, or misspelled
help flag is an ordinary refused invocation; selecting a machine format still uses that format’s
existing refusal envelope.
amiss --version also stands alone: any second token makes it an ordinary, refused invocation. It
opens no repository. It prints two lines and exits 0:
amiss <version>
engine sha256:<64 hex digits>
The first line is the binary’s version. The second is the engine_digest, computed by
hashing the executable’s own bytes: the same digest every report stamps and the
release manifest
pins per platform. So an installed binary matches a release row, and a report matches
back to the binary that produced it, without running a scan. A binary that cannot read
its own file prints engine unavailable and still reports its version. The other shipped
binaries answer --version with one line each: amiss-bootstrap, amiss-manifest,
amiss-constraint, amiss-probe, and the three provider services.
Profiles and findings
A finding is one fact the scan established, and four of its parts carry the story. The
kind says what happened. The attribution says whose change it is: introduced by this
candidate, pre-existing before it, resolved by it, not-applicable when the
before-and-after framing does not apply, or unknown when the match-up could not be
decided without guessing. The disposition says what the run does about it, and it comes
twice on every row: configured is what the rules asked, effective is what happened, and
only the effective one decides the exit. record is noted, warn is shown, fail
blocks. The location says where, down to byte offsets. The full row carries more,
twenty-one members; The report holds the shape.
The profile picks the built-in disposition for each kind. Six kinds flip between the
columns: the three structural reference failures, both value-claim kinds, and projection drift
warn under observe and fail under enforce. Seven control kinds fail under both profiles, one
kind warns under both, and the remaining thirteen are records. The exact table below copies
FindingKind::built_in_disposition,
and CI checks the two stay equal.
enforce-introduced is the ramp between the two. It applies the enforce column, and
after repository policy and any floor have raised what they raise, it lowers every
failing finding whose attribution is pre-existing to warn, writing a
scanner-policy-defaults/<kind>/enforce-introduced step into the row’s trace. Configured
stays fail; effective becomes warn. The backlog stays visible and counted while
anything the comparison introduced still blocks. An attribution the engine cannot
establish keeps its enforce disposition. And an organization floor whose minimum is
enforce does not merely refuse the ramp: the run ends incomplete at exit 2 with a
control-binding mismatch.
| Finding 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 |
site-build-defect | warn | fail |
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 |
claim-broken | warn | fail |
claim-target-missing | warn | fail |
projection-drift | warn | fail |
What each kind means
One fixed sentence per kind, copied from
FindingKind::meaning
and checked against it in CI. The machine report carries the same sentence on every
finding row, so this page is a reference, not a second source of truth.
explicit-target-missing: a reference names a repository path, a line range inside one, or a heading anchor no known renderer publishes; restore the target or correct the linkexplicit-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 targetinvalid-reference: the destination cannot name a repository target: it escapes the repository or carries a backslash, an encoded separator, or control bytes; fix the destinationtarget-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 clearedunsupported-reference-semantics: the reference uses semantics this run did not evaluate: a site route, a protocol-relative destination, a query string the selected grammar does not recognize, a destination that needs a document attribute this run does not evaluate, or a fragment on a target it cannot answer for; the unchecked part is declared instead of guessedunsupported-document-format: a document this run discovered has no parser in this engine, whether a markup it does not read or a policy include; it is counted, and its content is never scannedunsupported-target-kind: the reference resolves to a symlink or submodule, which Amiss does not follow; the boundary is declared instead of crossedunsupported-version-scope: a forge URL names this repository at another named version or an exact commit whose required objects are unavailable; use the candidate ref, or make the exact commit availableunsupported-capability: a candidate document declares a reserved amiss: capability this engine does not implement; the run ends incomplete rather than guessing at the claimdependency-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 wrongdependency-and-subject-cochanged: the referenced content and the block citing it changed together, the shape of a maintained page; recorded with nothing to act onsubject-changed: the block holding the reference changed while its target did not; recorded so prose moving over an unchanged dependency stays visibleexplicit-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 wrongdocument-removed: a scanned document left the tree; recorded so the disappearance is a stated fact rather than a silent oneopaque-mdx-region: an MDX expression region the parser cannot see into; a reference inside it is a stated blind spot, reported with size and placeopaque-html-region: a raw HTML region the parser cannot see into; a reference inside it is a stated blind spot, reported with size and placeobservation-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 undecidedunlinked-document: a scanned structured document inside a complete site build’s source root is unreachable from every rendered navigation entrypoint; link the page from rendered navigation or keep non-page material outside that rootsite-build-defect: a complete site build reports a route with conflicting owners or a redirect whose declared terminal route or anchor is not uniquely published; repair the route table or its available routing sourcepolicy-weakened: the candidate loosens its own repository policy, dropping an include, a protected path, a projection assertion, or a raised disposition; loosening the rules is reported under the rules being loosenedcoverage-reduced: a protected path is gone or not a scannable document while its protection stands; restore it or amend the protection in a reviewed changecontrol-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 visibledebt-worsened: the finding an accepted debt item names no longer matches the recorded fact; debt tolerates exactly the recorded state, so any drift failsdebt-expired: trusted time reached a debt item’s expiry while its finding persists; fix the finding or renew the debt in a reviewed changewaiver-invalid: a waiver item cannot apply, expired against trusted time or issued outside the floor’s authority; an invalid waiver suppresses nothingclaim-broken: a value claim’s target line no longer says what the document claims it says; update the claim or the target so the two agreeclaim-target-missing: a value claim names a target line no regular file in the candidate can answer; point the claim at a tracked file and a line inside itprojection-drift: a policy-owned projection cannot prove that its visible code block equals its selected repository source; restore its unique sink and source or make their projected bytes agree
Before and after
Only the shown state changes. Floor, debt, waiver, and trusted-time examples use the control API described in Controls and policy.
| Finding 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 | docs/spec.rst is absent. | Add docs/spec.rst containing Title and an ===== underline; .rst is discovered and has no parser. |
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]: <amiss:reference/path-exists?path=docs/a.md>. |
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 <Note>{"hidden"}</Note>. |
opaque-html-region | page.md: [Parser](src/parser.rs). | Append a separate <div class="card">hidden</div> 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 | Complete site-build evidence proves every scanned source beneath docs/ reachable from its rendered homepage. | Add docs/orphan.md without adding a rendered navigation path to it. |
site-build-defect | Complete site-build evidence maps /old/ to the unique published route /guide/. | Change its attributed redirect rule to target absent route /missing/. |
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. |
claim-broken | Cargo.toml line 3 is version = "0.16.0" and a claim expects exactly that. | Bump line 3 to version = "0.17.0" and leave the claim unchanged. |
claim-target-missing | A claim names Cargo.toml line 3, which exists. | Delete Cargo.toml or point the claim at line 9999. |
projection-drift | A policy assertion selects examples/request.json lines 1–12, and the adjacent code block has the same projected bytes. | Change the selected lines without updating the visible code block. |
The control families exist so that loosening the rules and leaning on an invalid waiver
are themselves visible findings. Repository policy may raise only
explicit-target-missing, explicit-target-type-mismatch, and invalid-reference, as
the policy parser and evaluator
enforces. A rule naming a lower disposition is a no-op, and dropping one the base carried
is policy-weakened. Repository policy has no suppression syntax at all. The only
lowerings anywhere are the ramp above, a verified debt item, and a verified waiver, each
leaving a trace step and none removing the row. The way to remove a repository-policy
finding is to fix what it points at.
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:
- 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. A repository whose
backlog outlives its first triage can gate the middle of that road with
enforce-introduced, which blocks what a pull request introduces while the carried
findings stay warnings in the same reports.
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 |
pull_request_target | the payload’s base tip | the pull request’s head |
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
that stores an open build-source identity instead of assuming github.com; the
release workflow
is a checkable example of that input. The report and request formats are forge-neutral.
Pinning the Action
The moving major ref follows the engine’s semver major, v0 for the 0.x series and v1
from 1.0.0 on, so one series can never rewrite another’s ref. A vX.Y.Z source tag is an
immutable exact pin whose dispatcher delegates to the equally immutable action/vX.Y.Z
runtime tag; a source commit pins the dispatcher but still makes that second hop. Pin
action/vX.Y.Z directly, or its generated Action commit, when policy requires the complete
runtime tree in one ref.
Invoking the engine directly
The long form is useful outside GitHub Actions or when a workflow constructs the exact
evaluation itself. Amiss’s own
self-scan workflow
builds the pull request’s engine, assembles a local action tree with its manifest, and runs
that composite under --profile enforce. A minimal adjacent-commit direct invocation is:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 2
persist-credentials: false
- run: cargo install --locked --registry crates-io --version '=<reviewed-version>' amiss
- env:
REPOSITORY: ${{ github.repository }}
BRANCH: ${{ github.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 <reviewed-version> with the exact release you reviewed. The leading = makes the
Cargo requirement exact, Cargo checks the crate archive against the crates.io index checksum,
and --locked refuses to recompute the packaged lockfile, so the command pins both the
released crate and its dependency graph. The placeholder is deliberately release-independent.
Repository and branch names travel through environment variables because a branch can be
named anything and text pasted into a shell script becomes code; the owner is lowercased in
shell because GitHub hands it over with its registered capitals and Amiss refuses anything
but lowercase. A scan is a pure function of the two snapshots and the invocation, so there is
no baseline cache to warm between runs. As with the Action, graduate to --profile enforce
once the first report is triaged.
The external rail extends any direct invocation into web evidence, advisory: derive
the plan from the written report, probe its introduced destinations,
and judge through the assessment. Amiss runs exactly this chain
on its own pull requests, in the external-advisory job of the same workflow linked
above, with every defect degrading into one summary line rather than a failed check:
- env:
GH_TOKEN: ${{ github.token }}
run: |
gh release download v<reviewed-version> --repo HardMax71/amiss --pattern amiss-probe-linux-x86_64
gh attestation verify amiss-probe-linux-x86_64 --repo HardMax71/amiss \
--signer-workflow HardMax71/amiss/.github/workflows/release.yml
chmod +x amiss-probe-linux-x86_64
- run: |
amiss external-plan --report amiss-report.json --format json > amiss-plan.json
./amiss-probe-linux-x86_64 --plan amiss-plan.json > amiss-evidence.json
amiss external-assess --plan amiss-plan.json --evidence amiss-evidence.json
The assessment refutes only what a probe or forge API positively disproved, so its rows
are telemetry to read, not a gate to wire, until the rates have earned that. Its human summary
also windows permanent-redirect retarget suggestions; temporary redirects remain evidence and
never become edit suggestions. The prober
ships beside the engine in every release, in the same SHA256SUMS and sigstore bundle,
so the download above is the same Verified consumption recipe with the
pattern changed. A release cut before the prober has no such asset; there the source
build cargo build --locked --release -p amiss-probe stands in, which is also what the
dogfood job runs so it probes with the pull request’s own prober.
That dogfood job uses
GitHub’s cache
only to replay successful, nonempty evidence when the same workflow run is retried. The exact
generation key carries the cache schema and probe options, runner platform, immutable run and
commit identities, and the attempt number. Restore fallback is confined to that prefix, so only an
older generation from the same run and commit can match. The first attempt restores nothing, and
empty evidence saves nothing. A restored file is still untrusted input: external-assess must
accept its exact plan binding before the probe is skipped. A miss, cache outage, or invalid body
runs the probe again; nonempty corrected evidence is saved as the current attempt’s generation for
later retries. Every attempt derives the assessment locally. A new workflow run therefore never
inherits an observation from the old one, and the cache never becomes a baseline or changes the
advisory policy.
The SARIF projection turns the same run into GitHub code-scanning alerts, inline on the lines the findings name, with fixes rendered as suggested edits and the finding key deduplicating alerts across runs. Two steps after any direct invocation:
- run: amiss check <the check flags above> --format sarif > amiss.sarif
- uses: github/codeql-action/upload-sarif@24c7eb380a2dc368f2d129e4c65e51d172983a1e # v4
with:
sarif_file: amiss.sarif
category: amiss
The category keeps Amiss’s alerts distinct from any other SARIF producer in the
repository, and the upload needs the workflow’s security-events: write permission. The
uploaded rows are ordinary code-scanning alerts, so GitHub’s remediation surfaces,
agentic autofix
included, operate on them directly. What each result carries is stated in
The report.
On GitLab the whole job ships as a pinned template. GitLab’s CI/CD Catalog only serves components hosted on a GitLab instance, so a GitHub-hosted project publishes the honest equivalent: a template consumed as a remote include from a tagged URL.
include:
- remote: https://raw.githubusercontent.com/HardMax71/amiss/v<reviewed-version>/integrations/gitlab/amiss.gitlab-ci.yml
variables:
AMISS_VERSION: v<reviewed-version>
Both pins name the release you reviewed and move together. The
template
runs on merge-request pipelines, refuses to run until AMISS_VERSION is set, verifies
the downloaded binary against the release’s SHA256SUMS before executing it, scans the
merge request’s diff base against its head under AMISS_PROFILE (observe until the
first report is triaged, the same ramp as everywhere else), renders Code Quality from that
same validated report without a second scan, and uploads two artifacts:
the exact JSON report, and a
Code Quality report rendered in the
merge-request widget and inline on the diff. The fingerprint is the finding key, so the
widget’s new-versus-resolved diff follows the same identity the report uses. This is
rendering, not the trust lane: a blocking run still fails the job by exit class, and the
provider-verified gate is the GitLab policy lane.
On Gitea and Forgejo the published Action runs unchanged. Gitea Actions resolves
uses: references through github.com by default, so the same two steps shown at the top
of this page work in a .gitea/workflows/ file verbatim: verified on Gitea 1.24.7 with
act_runner 0.6.1, where a broken reference failed the job with the engine’s exit class
and its file annotation, and the repaired push went green. This is the convenience
surface, not the Gitea and Forgejo provider lane, whose own floor
is stated there.
Reading a run
When a run blocks, use the grouped feedback to orient, then read the exact JSON findings for
repair evidence. The Action and human views show at most ten Fix and Check items combined, in
engine order, 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:
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 compares those lanes and links their setup, and Controller delivery documents the shared retry record; the GitHub lane’s own page is GitHub provider lane.
Before a commit exists
The same check runs on the staged index. The repository publishes a
pre-commit hook that scans the staged state against HEAD with an
installed amiss binary:
repos:
- repo: https://github.com/HardMax71/amiss
rev: v<reviewed-version>
hooks:
- id: amiss
Replace v<reviewed-version> with the exact release you reviewed, the same convention
as every version pin on this page. When the staged check reports fixes, amiss fix applies them to the
working tree in place; restage and the same hook judges the repaired state.
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. The staged loop works from a
linked worktree too, reading that worktree’s own index, so agents running in worktree
isolation keep the same gate. If the repository keeps an
AGENTS.md, paste this section into it. A repository that keeps a CLAUDE.md instead
takes the same block there, or bridges the two the way this repository does, with a
CLAUDE.md whose whole body is @AGENTS.md:
## 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.
Copied into .github/workflows/ and compiled with the gh aw extension, it runs the
scan on a schedule and reads the report. What it can prove it repairs, and the pull
request it opens passes back through the same gate it started from. Findings that carry
a fix hand the agent the exact edit: the document, the byte span, and the replacement
text, already proven against the grammar that will re-check them.
Claude Code
This repository doubles as a Claude Code plugin marketplace. One command registers it:
/plugin marketplace add HardMax71/amiss
Installing the amiss plugin from that marketplace adds a skill that knows the
invocation grammar, the exit classes, and the fix loop; its text is maintained at
integrations/claude.
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.
What refs name
Branch refs describe identity and link scope; they never select either snapshot. The rolling
request and report contracts carry candidate_ref, the candidate or source branch whose links
are being evaluated, separately from target_ref, the protected branch to which branch-scoped
controls bind. A direct branch update normally uses the same value for both, while a pull or
merge request may use a feature branch as the candidate and the protected base branch as the
target.
The default-branch ref is a third fact, used for URL resolution and never inferred to be the
target. The public CLI exposes only its existing candidate --ref claim; the complete split
is currently reachable only through the internal request and bootstrap surface.
The candidate identity
In staged-index mode the identity covers the complete logical stage-zero index, including skip-worktree entries. The digest chain has three steps: hash the sorted index projection, hash a synthetic snapshot over that projection, then bind the result into the staged candidate identity. A commit-pair run uses the corresponding commit candidate identity.
Both refs are part of that identity preimage, alongside the repository, selected URL dialect, base, and candidate, so a trusted-time statement bound to one source and target relationship cannot be replayed for another. These JSON files are digest preimages, not accepted request documents, and the identity golden test validates each against its report-schema definition and reproduces the full chain through the production builders.
The snapshot request’s repository_handle: 3 is a stable protocol ordinal, not a claim that
the operating system passed file descriptor 3. In the current safe-Rust subprocess path the
bootstrap maps that logical handle to the fixed repository working directory before launch,
and the engine opens only that directory. A future isolation backend may map the same ordinal
to a different pre-opened mechanism without changing the request wire.
The provider identity
The provider controller uses a stricter orchestration identity containing the provider instance, repository and change, URL dialect, candidate, target and default-branch refs, object format, base and candidate commits, and both tree IDs. Each provider lane binds that identity from independently authenticated input, refreshes the change and protected merge gate through its own credential, and acquires authenticated SHA-1 commit wants through the same fixed-budget Git protocol-v2 path before launch. Bootstrap refuses unless the acquired roots reproduce the evaluation identity. The engine then reads and re-hashes their objects normally; fetching remains outside the engine. The provider’s Check Run, policy-job result, or dedicated review remains merge evidence rather than a third engine snapshot.
Reading Git directly
Amiss reads Git’s storage itself instead of asking the git command.
Loose objects, packfiles, deltas, and the index file are parsed by the engine, and the
parsers reject instead of repairing. A tree with entries out of order, an index whose
checksum does not match, a delta chain deeper than the published limit: each one is a typed
refusal, never a best-effort read. Every SHA-1 object is re-hashed as it is read, with
collision detection switched on, so an object that does not hash to its own name simply does
not exist as far as the evaluation is concerned.
The supported repository forms are the non-bare checkouts: a primary checkout with a real
.git directory, a linked worktree, or a separate-git-dir checkout, the latter two through
one bounded gitdir: indirection and at most one bounded commondir hop, with symlinked
targets refused. A bare repository is refused directly, though its linked worktrees read
through their commondir, and objects available only through Git alternates are not
consulted. These are explicit boundaries of the direct
repository reader,
not empty snapshots or silently missing documents.
File access happens through directory handles opened step by step, never following links. A
symlink, junction, or reparse point at the repository root, at .git, at objects, or
anywhere along the path to an object is refused outright. The refusal is a different error
from the object being absent, and that difference is deliberate: someone who can plant a
link must not be able to make the scanner read files outside the repository, and must
equally not be able to disguise the attempt as a missing object.
What a refusal looks like
In the report’s errors array, a base commit the store does not hold appears as:
{
"code": "GIT_OBJECT_MISSING",
"phase": "git"
}
The row also carries path, path_bytes_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. .adoc and .asciidoc are structured-asciidoc, and .rst is
structured-rst. .txt stays off both lists: the suffix says nothing about what is inside.
Django can state its convention without admitting every file under docs by using one exact
tree-and-suffix selector bound to rst; unrelated suffixes remain outside. .ipynb and .org
are unparsed-markup: this engine has no parser for a notebook or
for Org markup, so those files are discovered and counted as unsupported-document-format
and their content is never read, the same honest count an unbound policy include reaches.
Notebook Markdown has measured reference yield, but its decoded cell spans are not physical JSON
spans and the current report cannot name a cell; the
notebook measurement records why that
format remains visible but unparsed. Quarto’s .qmd suffix remains outside the document set,
while MyST’s .md files receive only the CommonMark/GFM semantics their suffix promises. The
adjacent-format measurement records
why MyST, Quarto, and Org need renderer-aware adapters rather than suffix aliases.
An include may instead bind one of the five built-in adapters, which reads the named path or
tree under that grammar without installing another parser. One grammar answers per path per
evaluation: the candidate policy’s bindings, or the base’s when the candidate carries none,
and dropping a binding while keeping the include is policy weakening under
policy/include-binding-removed. Every
other file is a possible reference target, not a built-in document. These rows come directly from the
classifier.
Nine directory names are always skipped, wherever they appear in a path:
node_modules vendor third_party dist build .next target test tests
The names are fixed: no configuration adds one or takes one away. A repository policy can
still readmit coverage underneath a skipped name, because policy adds coverage and never
removes it: a document include admits its one exact path, a plain tree include admits the
whole subtree, and a suffixed tree include admits only its exact tail. That is the monorepo
lever. A package legitimately named build, dist,
test, or tests keeps its prose scanned through one tree include in
the repository policy, and there is no mechanism in the other direction:
Amiss always reads the whole repository, so a monorepo cannot scope a run down to one
package. Skipping is
visible in both directions: skipped documents still show up in the report’s counts, as
excluded. This repository relies on the rule itself: its vendored parser test corpus lives
under corpus/third_party/ exactly so that fixture files full of deliberately broken links
are never read as prose.
test and tests joined the list on measurement. Across fifteen public repositories, 391 of
3,934 discovered documents sat under one of those names and carried 400 references between
them, about one apiece, while producing 45 of the missing rows, every one a deliberately
broken fixture. Two were prose: the READMEs explaining pydantic’s own test suites. Prettier
is the case that made it a defect rather than noise, since seven intentionally malformed MDX
fixtures under tests/format/ refused its entire run; it scans now, and the first thing it
reports is a real break in its contributing guide.
Nine paths through the classifier:
docs/guide.md structured-markdown scanned
site/page.mdx structured-mdx scanned
README extensionless-markdown scanned
llms.txt plain-advisory scanned, nothing extracted
docs/guide.adoc structured-asciidoc scanned
docs/guide.rst structured-rst scanned
notes/plan.org unparsed-markup counted, never read
vendor/lib/README.md excluded the vendor component is in the closed set
src/parser.rs not a document a reference target only
Markdown and MDX recognize frontmatter only at byte zero, optionally after one UTF-8 BOM.
The first complete line must be exactly --- or +++; the closing line repeats it, except
that --- also permits .... A recognized region is opaque to the document grammar and may
contain at most 65,536 bytes, excluding the BOM. An opener without a permitted closer, or a
closer past that bound, remains ordinary document text. The published
frontmatter vectors execute this boundary,
including LF, CRLF, bare CR, BOM, and exact-limit cases, through the production recognizer in
the frontmatter test.
A reference definition whose decoded label begins with exact lowercase amiss: is a
reserved governed claim. Entity and escape decoding happens before that test; case is not
folded. Every reserved definition node contributes its exact source digest, including a
losing normalized duplicate, and only the first normalized definition controls whether a
consumer becomes an ordinary reference. A governed definition spelling the closed value
grammar is evaluated as a claim, described in Claims. Everything else in the
namespace on the candidate side is an unsupported capability boundary: the run ends
incomplete with exit 2. A base-only definition does neither. The
governed-definition vectors
drive extraction, source hashing, candidate-only grouping, and report construction in the
governed test,
including one refusal vector per clause of the value grammar.
Every count is reported: discovered, scanned, unsupported, excluded, unlinked. The last count is
zero unless trusted complete site-build evidence supplies a source root, navigation manifest,
rendered entrypoints, and the source documents reachable through the completed HTML link graph.
unlinked-document then names a scanned structured document inside that root which is neither the
manifest nor reachable. Outbound-reference counts never stand in for navigation evidence. The exact
predicate is in the document finding evaluator.
Paths are treated as bytes. Amiss does not fold case and does not normalize Unicode,
because Git addresses files by exact bytes, and a checker that guesses two names are
equivalent will eventually insist that two different files are the same file. A name whose
bytes are not valid UTF-8 is still a name: the entry is classified by the same suffix
rules, scanned, and reported, with its path written as a bytes_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
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, autolinks, and any reference definition no reference in the document consumes,
since an orphaned [api]: ./guide.md still maintains a destination someone will trust.
Each occurrence keeps two spellings of its destination. The raw one is the exact bytes from
the source. The semantic one is what those bytes mean after the format’s own decoding. So [a](&b) records both &b and &b, and a change to
either the spelling or the meaning is visible later.
What the parser cannot see into is declared instead of skipped. Raw HTML blocks and MDX
expressions become opaque regions, reported with their size and place as
opaque-html-region and opaque-mdx-region findings, so a link hidden inside JSX is a
stated blind spot rather than an invisible one. An HTML region still yields what a
renderer would follow: <a href> and <img src> values resolve like any markdown
destination, character references decoded into the semantic spelling, alongside the
headings and id attributes the anchor tables already harvest. A tag spelled inside a
comment or a script, style, textarea, or title body is followed by no renderer and is
never mined, and the rest of the region stays the declared blind spot. Raw output
injection is opaque in every dialect: AsciiDoc passthrough blocks and reStructuredText
raw directives inject output the parser cannot read and count as opaque regions too.
Markdown and MDX draw the line wider and treat every raw HTML region as opaque, comments
included, while AsciiDoc and reStructuredText code blocks, literal blocks, and comments
render as visible text or not at all and are never opaque.
Each destination then passes through the generic
resolver;
trusted absolute forge spellings continue through the private
dialect module.
A relative path resolves from the document’s own directory and must stay inside the
repository; ../../../etc/passwd is an invalid-reference, not a file read. A path beginning
with / is a site route, not a repository-root shorthand. It stays unsupported unless sealed,
candidate-bound site-build evidence maps that exact route and optional decoded anchor to a
published source-backed or generated page, either directly or through a proved fragment-aware
terminal redirect. Forge URLs need the complete identity group, not only the repository name. When
the invocation provides --repository, --ref, and --default-branch-ref and
selects a dialect, a URL on the declared host that names the same repository in that
dialect’s spelling is converted to a path when it names the candidate branch or one full lowercase
object ID in the run’s declared SHA-1 or SHA-256 format. Exact IDs on all five dialects resolve
only through that commit and its objects already present under the declared Git roots. A completely
walked local tree can prove a missing path; an unavailable commit, tree, or target object instead
retains unsupported-version-scope with the exact commit and contained path, plus the decoded URL
for the provider-evidence layer. The engine still fetches nothing. A named branch or tag outside the
candidate remains version-scoped without guessed commit identity. Forge query semantics remain
unsupported except for Bitbucket Cloud’s canonical fileviewer=file-view-default presentation
choice and Bitbucket Data Center’s exact revision selectors. Transclusion-dependent historical
absence remains unsupported because an object walk is not a historical site build. A URL outside the
declared repository is external. It records the decoded destination so the
layer that does fetch can read the list without walking the tree again, and it raises no finding,
because there is nothing it decided.
Five dialects exist, each pinned to the exact URL grammar its forge’s browser emits.
The github dialect reads owner/name/blob-or-tree/ref/path and serves GitHub and any
GitHub Enterprise host the identity declares. The gitlab dialect reads the canonical
separator form group[/subgroup...]/name/-/blob-or-tree/ref/path, nested groups compared
whole. The gitea dialect serves Gitea, Forgejo, and Codeberg with typed selectors:
src/branch/ splits like the others, src/commit/ resolves its full lowercase object ID from the
local object database and retains a known immutable scope when those objects are unavailable, and
src/tag/ is always out of version scope because no tag is a trusted ref.
The bitbucket-cloud dialect
reads owner/name/src/commitish/path; Cloud places the
commitish in one segment, so another branch or tag still retains a known path, while a candidate
branch containing / cannot match that form. Its canonical default-viewer query is presentation
only. The bitbucket-data-center dialect accepts an optional literal installation context with no
projects or users segment, followed by
projects/<key>/repos/<name>/browse/<path> or the corresponding users/<slug> personal route.
No query means the declared default branch; at= must carry the exact candidate ref, another full
branch or tag ref, or one full object ID. The history selector Atlassian documents as
until=<oid>&untilPath=<path> is accepted only when the full ID has the run’s object format and the
decoded path repeats the browse path. These boundaries follow Atlassian’s
repository route,
commit browsing, and
history URL
contracts. Line anchors follow the forge: #L10-L20 is a line reference on github and gitea,
#L10-20 on gitlab, #lib.rs-10 on bitbucket-cloud when lib.rs is the target’s exact
basename, and #10-20 on
bitbucket-data-center. Relative references use the run’s declared dialect
when one is present. A recognized reference’s
intent kind names the dialect that read it, not the host, so an Enterprise repository’s
links carry the same kind GitHub’s do. A branch spelled exactly like a full object ID is refused as
ambiguous rather than assigned whichever interpretation happens to win on a forge.
One document, every destination shape:
[guide](guide.md) resolves beside this document
[guide](guide) resolves to guide.md, the spelling a router serves
[site](/docs/guide/) resolves only from matching sealed site-build evidence
[escape](../../../etc/passwd) invalid-reference: it leaves the repository
[dir](sub/) the author promised a directory
[gh](https://github.com/o/r/blob/main/src/lib.rs) a path only for o/r, github, and --ref refs/heads/main
[lines](../src/lib.rs#L45-L48) exact inclusive line selection under github or gitea
[web](https://example.com/manual) external: recorded with its destination, never fetched
[anchor](guide.md#setup) resolves when a known renderer publishes that heading identity
The same decision, drawn:
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 holds the spellings, the routers
they were harvested from, and what the union costs.
A destination no spelling reaches is kind: missing with reason: path-not-found, and
that row carries near: the one tracked path equal to the missed one apart from case,
when exactly one exists and null otherwise. It answers the break a case-insensitive
working copy hides, where Guide.md opens locally and resolves nowhere on the tree the
forge and Linux CI read. A repository holding both spellings names a real ambiguity and
stays bare. A lone reference whose written path part is the missed intent’s exact tail
turns that neighbor into the finding’s fix, replacing only the bytes the author wrote
while a fragment rides untouched beside them.
When the missed path existed in the base tree and disappeared from the candidate, the same
resolution also carries same_object_at if exactly one candidate-added entry has the identical
Git mode and object ID and that identity belongs to exactly one removed path. Copies, duplicate
content, mode changes, and edited moves leave it null. This is candidate-tree evidence that Git
stores identical bytes at another path, not evidence of author intent: it never supplies a fix
or replacement bytes. The case-only near fact remains independent.
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.
AsciiDoc destinations reach one rule of their own before anything else. A target still
holding {name} cannot be a path, because the value arrives when the site is built and this
engine reads two trees, so it is unsupported-reference-semantics rather than a guess at a
directory called {name}. Across Quarkus that is roughly a quarter of every reference, so
reporting them as missing would have buried the real breaks. The double-angle shorthand keeps an
unambiguous document.adoc#anchor as an inter-document target rather than turning the entire value
into a local ID. A heading anchor on an AsciiDoc target resolves through the Asciidoctor rule in
What twelve renderers call a heading, which is the only rule whose separator
is _ and whose identities all carry a prefix.
A reStructuredText heading anchor resolves through the Docutils rule in
What twelve renderers call a heading, and the labels a document declares
outright with .. _name: resolve as themselves. The two Sphinx roles are modelled by
name, which is why the grammar profile says docutils-rst-sphinx-refs. A relative
:doc: target takes the source suffix and resolves like any repository path, while a
source-root-absolute one stays a declared site route, because the engine does not know
the Sphinx root. A :ref: resolves against the snapshot’s label table, built during
discovery from every .. _name: a scanned reStructuredText document declares and
bounded by declared-labels-per-snapshot: a unique declaration resolves to its
declaring document, a name nobody declares is a missing target, and a name declared
twice is undecided rather than guessed between. Labels follow the Docutils simple-name
rule, case-folded with whitespace runs collapsed, a phrase declaration may arrive
backtick-quoted or sit inside a list item or grid-table cell, and an undeclared name
carrying a colon is treated as another project’s inventory, declared unsupported rather
than reported missing. A prefixless name absent from the local table can resolve only through one
unique label in complete, candidate-bound Intersphinx evidence supplied
through the sealed trust boundary. Local declarations retain precedence; duplicate external labels
stay unsupported, while absent, partial, stale, malformed, or mismatched evidence leaves the name
missing. The engine never fetches an inventory. Every other role stays an open extension point,
declared rather than read into.
Heading evaluation expands the closed local include subset in source order. An AsciiDoc
include::path[] or option-free, document-level reStructuredText include participates when its
literal relative target was already scanned under the same adapter; each nested path is relative to
the file that includes it. An option-free literalinclude contributes no parsed headings. The graph
is bounded by references-per-document, parser-nesting, and
aggregate-heading-anchor-evaluation-bytes-per-snapshot. A cycle, an unscanned or non-local target,
a build-time attribute, include options, or a nested parser context leaves the identities collected
up to that edge partial: a published identity can still resolve, but absence stays undecided rather
than becoming a guessed missing anchor. Expanded AsciiDoc remains partial even when every edge is
available because its document-attribute and conditional state is not reproduced; reStructuredText
can prove absence inside the closed option-free subset.
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. Twelve rules are pinned, one per renderer or
per configuration of one, and the resolver asks whether any of them would publish the
anchor, counting the headings a document writes as raw HTML and the identities it declares
outright, in raw HTML or in an attribute block, as well. An anchor no rule
publishes is kind: missing with reason: heading-anchor-not-found, an ordinary missing
target; the row also carries near, the one published identity the fragment names apart from
typography when exactly one exists and null otherwise. The fold covers the two spellings
the pinned rules disagree on, case and the separator character, so a duplicate suffix
written _1 folds together with -1; a lone reference over a verbatim-located fragment
turns that neighbor into the finding’s fix. 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 twelve renderers call a heading holds the rules, what each was checked
against, and how far apart they are.
What the check will not do is judge on a parse that did not happen. A target that is not a
parsing document class, an LFS pointer, a document the parser rejects, 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. The candidate is read, and a full immutable ID is read only from
objects already present under the declared Git roots; unavailable objects are delegated for
provider evidence instead of fetched. --default-branch-ref supplies a second trusted spelling so
the resolver can split a ref from its path without guessing, and a URL naming the default branch
while the candidate ref differs is still unsupported-version-scope. Site generators and
language-aware tools still own route and symbol semantics. A complete site-build producer can
contribute exact positive source-backed or generated route, anchor, and fragment-aware
terminal-redirect facts for the candidate; absent, ambiguous, or stale mappings remain unsupported,
while conflicting ownership and broken declared redirects become build defects retaining every
available source. Guessing beyond that evidence would turn honest ignorance into a false pass. The
resolver tests
pin these distinctions.
Each resolved target is read from the object store and hashed, so the comparison knows the
exact selected bytes and file mode on both sides. Numeric positions do not prove that those
bytes still mean what the prose claims; they only make movement and byte drift observable.
A symlink or submodule target is
unsupported-target-kind, because following one leaves the world of exact bytes where the
guarantees live. A Git LFS pointer file is recognized and its committed
pointer bytes are hashed. Those bytes include the declared OID, so an OID-text change is
observable; a backing-store change that leaves the committed pointer unchanged is not.
What twelve 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 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 |
docutils | Docutils and Sphinx | every non-alphanumeric run becomes one separator, so foo_bar is foo-bar; a leading digit run is stripped rather than prefixed; NFKD then ASCII fold, so Ⅻ chapter is xii-chapter |
asciidoctor | Asciidoctor and Antora, at the default idprefix and idseparator | the only rule whose separator is _ and whose every identity carries a fixed prefix; hyphens and dots survive as themselves; repeats number from _2 |
The AsciiDoc rule is the one pinned to a configuration rather than to a renderer’s only
behaviour. idprefix and idseparator are document attributes, and this engine evaluates no
attributes, so the rule holds their defaults and a document set that overrides either publishes
identities the rule does not know. Two divergences are known and unverified against a running
Asciidoctor: a title whose every character is filtered away publishes nothing here, and the
attribute-driven cases above.
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
<h1> and ## in one sequence: the text content of the element is slugged by the same
rule, nested tags and comments contribute nothing, and a repeat of an earlier identity
takes the next suffix. Forgejo does not, verified on
its own README, where <h1 align="center">Welcome to Forgejo</h1> is the only heading rendered without an identity while all four ## headings
carry one. The rules built from a Markdown tree,
mdBook, goldmark, python-markdown, pymdownx, mdit-vue and kramdown, never see the element
at all. So this is the github row’s behavior alone, and the union carries it.
What each rule was checked against
The published expectations are in heading-anchor vectors, which names the implementation behind every column. Twenty-four cases carry the divergences: punctuation runs, intraword underscores, precomposed and decomposed Latin, the Turkish dotted capital, CJK, a Bengali virama, an emoji variation selector, a Roman numeral, a no-break space, and a heading that filters to nothing.
Seven rules have a runnable implementation, and against those the table reproduces all
9,049 headings harvested from the ten repositories in The scan ledger with no
mismatch: github-slugger 2.0.0 and comrak 0.54.0 for github, goldmark 1.8.4,
python-markdown 3.10, pymdownx, @mdit-vue/shared, and kramdown’s own generator. The
remaining five are transcribed and traced by hand: Gitea’s CleanValue, Forgejo’s
prefixedIDs, mdBook’s id_from_content, Asciidoctor’s Section.generate_id, and Docutils’
make_id. The
published vectors
name which of the twelve is which and what each transcription is not checked against.
Eight documents, in
corpus/third_party/anchor-fixtures/,
carry what a renderer actually published for them, harvested 2026-07-26:
| 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 <h2> written across three lines publishes
--wrapped-title, the leading newline and two spaces intact, which is the kind of detail a
transcribed rule gets wrong and a harvest does not.
How far apart the rules actually are
Over those 9,049 headings, github and comrak agree on every one, which is why GitLab reads
the same identities as GitHub. gitea, forgejo and mdbook sit within 28 of them, and the
28 are combining marks, no-break spaces and connector punctuation. The site generators are
the outliers: goldmark’s default differs on about 1,117, python-markdown on 1,431, and
mdit-vue on 1,856.
Switching MkDocs to pymdownx.slugs.slugify moves 1,431 of the 9,049 and lands within 22 of
github-slugger, which is the measured reason a MkDocs answer is a configuration rather than a
renderer.
Renderer drift
Two of these implementations were rewritten inside twelve months. Gitea moved heading identities out of goldmark and into an HTML post-processor in January 2026, shipped in 1.27, which is where its missing duplicate suffix comes from. mdBook rewrote its generator in September 2025 under a new HTML pipeline. github-slugger’s character class exists only because of a 2021 commit to match GitHub on Unicode, and its one later change was a Unicode data bump made for the same reason.
That is what the fixtures are for. A re-harvest that disagrees fails a test instead of silently changing a verdict.
Translated trees
Translation mirrors are where anchor breaks concentrate in the wild. In the ledger’s survey, 103 of the 122 real anchor breaks sit in starship’s translated pages, every one with the same shape: the heading was translated, its slug moved with it, and the English fragment stayed behind in the links. A translated tree is a first-class scanned surface, since translated readers follow the same links, and nothing scopes it out of a run.
Two repairs hold up. Linking the translated heading’s own slug stays correct in each
language under the renderer rules this page pins. Pinning an explicit raw-HTML id on
the heading survives translation entirely, since the id is harvested as an anchor
identity (Resolution) and does not move when the heading text does.
Either way the check is the same one every other page gets: the fragment must name an
identity the target actually publishes.
What is not modelled
Renderers outside the table publish identities this check will not match, and a repository served by one of them can see an anchor reported missing that its own site resolves. Pandoc, Hugo’s non-github id types, Sphinx and Docusaurus’s custom slug functions are the known cases. 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 are that same shape: a target the tree
holds under a spelling the router maps.
So the resolver asks the same question the router does. A destination the tree holds is its own answer. A destination the tree does not hold is looked up again under the spellings a modelled router serves, and the first one that names a file resolves the reference to that file. Resolution places this in the order; this page holds the spellings and where they came from.
The three spellings
| 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, 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 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
<dt id=files> while the pinned grammar has no definition list to read at all; 28 name a
heading pulled in by an {{% include %}} shortcode. Jest, on Docusaurus, links a document by
the identity that document declares in its own front matter: Configuration.md opens with
id: configuration, its page is published at that name, and 104 references reach it by URL
rather than by path. 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. 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, arriving there as transclusion, as a repository’s own render hook, and as an identifier that was never a path. The exact-path core of that class is answered now, from the tracked ignore file recorded in Reference coverage, and all three of those arrivals sit outside it.
Correlation and impact
The base-versus-candidate comparison works per occurrence, and the unit it reasons about is the block: the paragraph, list item, or table cell that contains the reference.
Correlation has an exact phase and a conservative candidate phase. Equal observation
identities pair exactly. Among the remaining occurrences, a candidate edge exists only
when the adapter, source construct, and CorrelationIntent projection agree.
What counts as agreement depends on the reference’s class. Repository paths and same-repository forge links share one semantic class, so an equivalent spelling change can still correlate; that class binds path, target kind, query, and fragment. External, site-route, and unsupported references keep their raw destination identity, with the external class binding scheme, query, and fragment, and the remaining classes binding their kind, query, and fragment. The correlation-intent vectors pin those fields and the GitHub, GitLab, Gitea, Bitbucket Cloud, and Bitbucket Data Center equivalence rows through the production projection in the vector test.
A candidate edge normally stays within one document. The only cross-document exception is a
unique exact Git rename: exactly one removed path and one added path must share the same Git
mode and raw-evidence digest, and the occurrence’s source projection must be unchanged.
Duplicate document content disables rename correlation instead of forcing a tie-break. The
correlate integration tests
fix the matching boundary, while the amiss-scan correlation benchmark tracks its scaling.
The candidate edges form a bipartite graph. A component with one occurrence from each side is
a candidate match. If multiple counterparts are possible, the result is an
observation-correlation-ambiguous finding with attribution unknown; Amiss never chooses
one by input order. An occurrence with no counterpart is new or removed. Repeated equal
findings are subsequently merged into one fact carrying a multiplicity count.
For each matched pair, the two snapshots tell one of three stories:
subject-changed: the block holding the reference changed.dependency-changed-subject-unchanged: the selected target projection changed and the block did not. This is the finding the tool exists for, and it never blocks: the code moved and the prose did not, which is a reason for a person to look, not a machine’s verdict that the prose is now wrong.dependency-and-subject-cochanged: both moved together, which is what a maintained page looks like, recorded at the lowest level.
The two-sided comparison reduces to a quadrant:
| dependency unchanged | dependency changed | |
|---|---|---|
| block unchanged | no finding | dependency-changed-subject-unchanged |
| block changed | subject-changed | dependency-and-subject-cochanged |
And the finding the tool exists for, as a change:
fn parse(input: &[u8]) -> Ast {
- tokenize(input).fold(Ast::new(), Ast::push)
+ lex(input).try_fold(Ast::new(), Ast::push).unwrap_or_default()
}
The parser tokenizes the input and folds the tokens into the tree.
The code block moved and the paragraph did not: dependency-changed-subject-unchanged,
a warning in every profile, pointing a reviewer at the paragraph with the line and column
of the reference that ties them together.
Removals get their own kinds. explicit-reference-removed means a reference that existed
in the base is gone from the candidate; 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.
Claims
A document can do more than point at a file: it can pin what the file says. A value claim
is a reserved reference definition asserting that one line of one repository file,
terminator aside, is exactly one expected text. The scanner evaluates the claim on every
run, so the page that states a version number or a default stops being a promise and
becomes a checked fact. Authoring one is a single command:
amiss claim reads the line and prints a definition it has already
proven against the extractor and this grammar.
The grammar
[amiss:release-version]: <amiss:value?path=Cargo.toml&line=L3> "version = \"0.16.0\""
The grammar is closed, and every clause of it is load-bearing:
- The label after
amiss:names the claim. A name starts with an ASCII letter or digit, continues in letters, digits,.,_, or-, and holds at most 120 bytes. The name heads the finding’s rule id,claim/value/<name>, so it carries no slash. - The destination is angle-bracketed and spells
amiss:valuewith exactly two parameters,paththenline. The path is repository-root relative, taken byte for byte with no percent decoding, and must satisfy the repository path grammar. The line isLfollowed by a number with a nonzero first digit, at most sixteen digits, within the safe integer range. - The title carries the expected text, decoded by the CommonMark title rules. An empty title is lawful and claims an empty line.
A reserved definition that misses any clause is not a lesser claim: it stays an unsupported capability, and the run ends incomplete with exit 2, exactly as before the value kind existed. A reference definition is invisible in rendered output, so a claim adds nothing to the page a reader sees.
The carriers
Every structured format has an invisible construct that carries the same line. Markdown and MDX use the reference definition above. reStructuredText uses a comment holding exactly the one carrier line, and AsciiDoc a line comment:
.. [amiss:release-version]: <amiss:value?path=Cargo.toml&line=L3> "0.16.0"
// [amiss:release-version]: <amiss:value?path=Cargo.toml&line=L3> "0.16.0"
The comment carriers take their bytes literally: no entity decoding, the title in double
or single quotes, nothing after the closing quote, the comment at column zero, and in
reStructuredText nothing but blank lines after the carrier line, since a comment holding
anything more stays opaque exactly as before. The one recognized spelling is the whole
carve-out from comment opacity. amiss claim prints the Markdown line; prefix it with
.. plus one space for reStructuredText or // plus one space for AsciiDoc, and a
broken claim’s fix respells whichever carrier it lives in, marker included.
Evaluation
The claim’s target must be a regular or executable blob in the candidate snapshot. The line answers without the terminator that ended it, whatever spelling that terminator used, and the answer must equal the expected text byte for byte. Reading the target charges the same referenced-target and line-fragment budgets a reference line fragment charges, through the same cache, so a claim and a link to the same file cost one read.
Claims are evaluated on the candidate side only. A claim speaks in the present tense
about the tree it rides with, so there is no pre-existing exemption to ramp away: its
attribution is not-applicable, and the enforce-introduced profile treats a broken
claim exactly as enforce does.
Outcomes
An attested claim produces no finding. The summary counts it in governed_claims, and
unattested_claims counts the claims that failed, so the two numbers say how much of
the document’s asserted surface held.
A claim that fails produces one of two findings, warn under observe and fail under both enforce profiles:
claim-broken: the target line exists and says something else. The finding’s evidence carries the expected and observed digests.claim-target-missing: nothing can answer, and the evidence names why: the path is absent, the target is not a blob, the target is an LFS pointer, or the line is out of range.
Claims sharing one name in one document aggregate per outcome kind: every broken
member joins one claim-broken finding and every unanswered member joins one
claim-target-missing finding, each carrying its contributing source digests, the
way governed boundaries already aggregate.
The fix a broken claim carries
A claim-broken finding standing alone carries a machine-applicable fix: the whole
carrier respelled with the observed line as its expected words, marker included, the
byte span of the carrier to replace, and the document that holds it. The engine emits
the fix only when it can prove it: the rewritten carrier is parsed back through the
real extractor for its own format and must classify to the identical claim with the
new expected words, so an
observed line the quoted-title grammar cannot spell (a double quote, a backslash, a
control byte, or bytes outside UTF-8) leaves the field null, and so does a name whose
definitions aggregate, since grouped members share one finding but not one edit. A
claim-target-missing finding never carries a fix, because nothing derivable says
where the target went.
Policy-owned projections
A projection checks visible example text rather than an invisible expected title. Its relation
lives in .amiss/scanner-policy.json:
{
"document": "docs/api.md",
"name": "request-shape",
"projection": "code-text-v1",
"sink": "previous-code",
"source": {
"kind": "blob-lines",
"path": "examples/request.json",
"first_line": 1,
"last_line": 12
}
}
The Markdown or MDX document addresses the visible sink with an invisible definition immediately after its code block:
```json
{"operation":"check"}
```
[amiss:request-shape]: <amiss:projection>
Whitespace may separate the code block and marker; prose or another node may not. The name is unique within the document and joins the policy row to the marker. It is deliberately not encoded in the destination query, and neither the marker nor the source file owns the relation. That separation gives deletion safe behavior: a missing marker is drift while the policy survives, and a removed policy identity is policy weakening.
code-text-v1 converts CRLF and bare CR endings to LF, then removes exactly one terminal LF to
match the parser’s semantic code value. Every other byte, including indentation and trailing
spaces, remains significant. The first selector shown above takes inclusive raw source lines. A
movement-stable selector can instead name the bytes between two complete marker lines:
{
"kind": "named-region",
"path": "examples/request.json",
"start_marker": "// amiss:request:start",
"end_marker": "// amiss:request:end"
}
Each marker is a distinct exact printable-ASCII token of at most 256 bytes, and the complete line equal to that token must occur once. The scanner interprets no surrounding comment syntax or embedded occurrence as a boundary. It excludes both complete marker lines and refuses duplicate, missing, reversed, same-line, or non-UTF-8 regions as typed projection drift. Edits outside the region do not affect its projected digest. Both selectors use the existing bounded target cache and line-fragment meter; source bytes are never executed or fetched.
A complete tracked-path inventory uses the same visible sink without reading another source blob:
{
"document": "docs/examples.md",
"name": "examples",
"projection": "sorted-rows-v1",
"sink": "previous-code",
"source": {
"kind": "tree-paths",
"root": "examples",
"suffix": ".md",
"maximum_depth": 2
}
}
The root must exist as a tree in a commit or as a directory implied by the staged index. The source
filters the discovery map Amiss already completed: descendants at or above maximum_depth, with an
optional exact suffix, become root-relative UTF-8 rows. Regular, executable, symlink, and gitlink
paths participate; tree entries themselves do not. sorted-rows-v1 orders those rows by UTF-8
bytes and joins them with LF, without a terminal LF. No glob, repository rewalk, object read,
normalization, suffix stripping, or source-language rule is involved.
A qualifying non-UTF-8 path or path containing a control character refuses the projection instead of disappearing from or splitting the authoritative set. A path outside the repository grammar already makes the candidate globally incomplete before projection evaluation.
On a mismatch the report retains exact expected, observed, missing, and extra row counts and distinguishes a pure ordering defect. It copies at most the first 32 missing and 32 extra byte-sorted rows, at most 32 KiB per side, and states the exact omitted counts; duplicate visible rows count as extras.
decimal-count-v1 uses the same complete tree selection but projects only its member count. Its
visible value is the canonical unsigned ASCII decimal: zero is 0, a positive count has no leading
zero, and signs, grouping, labels, and whitespace are never accepted. Because no path text enters
that projection, qualifying non-UTF-8 and control-containing names still contribute to the exact
count. A mismatch reports the expected count and the observed count only when the visible value is
itself canonical.
An attested projection emits nothing. Every nonattested relation emits one projection-drift
finding under claim/projection/<name>, points at the visible code block when one is uniquely
addressable, and carries only digests and byte counts rather than copying either full value into the
report. No projection fix is emitted: a later rewrite feature must first prove an exact editable
content span,
reparse the whole document, and re-evaluate the relation.
The report
--format json writes exactly one line to stdout: the canonical JSON of the report, then a
newline. Canonical means RFC 8785 canonical JSON:
keys sorted, one byte sequence per possible document, so the same input through the same
engine binary always produces the same bytes. The payload facts agree across platforms; the
envelope’s own digests differ by build, because they name the exact binary that ran. Duplicate keys are rejected everywhere on input, and
the contract’s numbers are integers, never floats.
The outer envelope has three members: its schema, the payload, and payload_digest, a hash
of the payload’s canonical bytes. The payload carries its own schema, compatibility
(the wire’s own version, frozen at 1), 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.
An exact same-repository forge URL carries an optional commit_oid in its target intent and
finding-key projection. The engine resolves it only from that commit’s objects already available in
the declared Git roots. If the commit or any required object is unavailable, an
unsupported-version resolution retains known-commit with the exact ID and contained path; it
does not turn unavailable evidence into a missing target, and the decoded URL is retained for the
provider-evidence layer. Named refs and ambiguous ref/path splits retain the narrower known-path
or unknown-path forms instead of being guessed into a commit.
The sealed commit-pair path, including every provider lane, still reports
explicit-commit-pair and explicit-replay. Provider event and publication facts remain outside
the engine report.
A repository path anywhere in the payload has exactly one spelling. Valid UTF-8 bytes
travel as a plain string; anything else travels as {"bytes_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.
external_destination holds the URL retained by an external or unavailable historical resolution.
Most such URLs delegate evidence to another layer: an external URL or a same-repository exact
commit whose required local objects are unavailable. The occurrence keeps the URL after the
format’s own decoding so that
https://example.com/x?a=1&b=2 is recorded as the address a fetcher would request rather
than as the bytes the source spells. A locally resolved or disproved historical target has no
delegated destination. An ordinary external URL raises no finding and the summary counts it under
external_out_of_scope, because the engine never fetched it and so decided nothing. A Sphinx
label resolved through candidate-bound inventory evidence carries both the selected destination
and reason: "intersphinx-inventory", but counts as resolved and is not delegated again. A
generated route proved by complete site-build evidence similarly carries reason: "site-build",
counts as resolved, and has no external_destination because it names no external target.
The external plan derives the introduced and removed destinations from
a written report, and Amiss and link checkers shows the pipe that hands
them to the tool that does fetch.
Every finding carries its kind, its location with byte offsets, its attribution, the
policy steps that set its final disposition, and the digests of the facts underneath it.
The key_input that produced the finding’s identity is included too, so an external
system can recompute any finding’s identity from the report alone.
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 pre-existing failures and warnings become one existing per
target after them, so the backlog is listed, not only counted; existing_count stays the
number of those grouped subjects. Each item retains its affected-location count and
contributing finding kinds. A Fix may carry one candidate-side text-path annotation;
Checks and Existing items never do. The report retains every item. An incomplete
comparison instead emits exactly {"status":"unavailable"}, so scan failure cannot look
like zero feedback.
The envelope, down to its top-level keys:
{
"schema": "amiss/scanner-report-envelope",
"payload": {
"schema": "amiss/scanner-report-payload",
"compatibility": "1",
"engine": { "engine_digest": "sha256:..." },
"evaluation": {},
"controls": {},
"result": { "status": "fail", "complete": true, "exit_code": 1 },
"feedback": { "status": "available", "items": [], "existing_count": 0 },
"summary": {},
"documents": [],
"observations": [],
"findings": [],
"errors": []
},
"payload_digest": "sha256:..."
}
And one finding row from a real failing run, abridged to its skeleton:
{
"kind": "explicit-target-missing",
"description": "a reference names a repository path, a line range inside one, or a heading anchor no known renderer publishes; restore the target or correct the link",
"attribution": "introduced",
"effective_disposition": "fail",
"location": {
"path": "docs/src/introduction.md",
"side": "candidate",
"span": { "start_line": 49, "start_column": 1, "end_line": 49, "end_column": 38,
"start_byte": 2912, "end_byte": 2949 }
},
"finding_key": "sha256:56a75485757d90b5959298c05f6b0531139b016533db320905ee532e5dd42512"
}
Findings are sorted by finding key, a domain-separated hash of kind plus scope. Every
immutable commit identity is part of that scope, so equal paths in two commits remain different
targets for correlation, findings, debt, and waivers. A reference kind with no repository-path
component retains the empty-string sentinel in normalized_target_intent.path; a repository path
uses the ordinary text-or-byte path form. Every
finding and error row carries a description: the fixed engine-owned sentence for its
kind or code, stating what the row means and what to do about it, so no consumer needs a
second source to act on a report. Beside it sits fix, a machine-applicable rewrite or
null, whose own description is one of a closed set of engine-owned sentences named by
FixKind: when the engine can prove the exact edit, the field names the candidate document,
the byte span to replace, and the replacement text, and a finding whose correct content
is not derivable carries null rather than a guess. Three producers emit one today: the broken
value claim carries its definition respelled to expect the target’s current line,
proven by classifying the rewrite back through the claim grammar (see
Claims), and a lone drifted heading anchor carries its fragment
respelled to the one published identity it names apart from case and separator style,
over bytes the adapter located verbatim, and a lone case-drifted path carries its
written path part respelled to the one tracked path it matches apart from case.
amiss fix applies these spans to the staged working tree in
place, refusing any document whose bytes moved since the evaluation. The sentences live in one place,
FindingKind::meaning, AnalysisErrorCode::meaning, and FixKind::meaning;
the lists in Profiles and findings and Limits and refusals
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.
--format sarif writes exactly one line to stdout: a SARIF 2.1.0 log projected from the
same payload. Every finding row becomes a result under its kind’s rule, fail as error,
warn as warning, and record as note, with the row’s own description as the message,
and a row carrying a fix projects it as a SARIF fix with the byte region and replacement,
which GitHub renders as a suggested edit
and the finding key riding as the stable partialFingerprints entry, so an ingesting
scanner deduplicates across runs by the same identity the report uses. A location renders
when the wire path is printable text, percent-encoded into the artifact URI so a hostile
path cannot break it. Retained analysis errors become tool execution notifications, an
incomplete run reports executionSuccessful false, and a rejected machine invocation
still answers in SARIF with exit class 2. Like the human form, the projection cannot
change facts, ordering, totals, or the exit class; the canonical report stays the only
wire, and consumers that need the full evidence read it there.
--format codequality projects the same payload as GitLab’s Code Quality artifact: a
JSON array with one issue per finding row in report order, the row’s description as
the issue text, its kind as check_name, and fail as major, warn as minor, and
record as info. The finding key rides as the fingerprint, so GitLab’s diff of target
against head recognizes the same finding across runs by the identity the report uses.
GitLab requires a path and a first line on every issue, so a byte-named document answers
with the wire’s hex spelling, a finding on no file answers as (global), and a byte-only
span reads as line one. The format has no
shape for analysis errors or a refusal: a rejected invocation answers with a valid empty
artifact, the exit class still carries the truth, and error detail stays on the JSON and
human lanes. The same projection bounds apply.
A render-only --format junit projects one suite for generic CI test dashboards. Findings
become cases under their stable finding keys: fail becomes a failure, while warn and record
remain passing cases with their exact disposition in system-out. Retained analysis errors become
error cases. A row-free passing report emits one passing report case so the artifact remains
visible. File attributes carry only text that round-trips exactly through XML 1.0, and time is
always zero because the report records no duration. JUnit remains display data—the renderer’s exit
is still the report’s recorded verdict.
A JSON report can be projected later without repeating repository evaluation:
amiss render --report amiss-report.json --format sarif, --format codequality, or
--format junit emits an alternate deterministic view. SARIF and Code Quality are byte-identical
to their originating check projections; JUnit has no direct-check form. Human is available too;
adding --full to that human replay emits every feedback row rather than the two ten-item windows.
The renderer verifies the active envelope and compatibility, the payload digest, and the recorded
result tuple, then exits with that report’s original verdict. JSON is not a render target because
the input file is already that canonical projection.
The report is evidence of engine evaluation, not a self-authenticating provider attestation. A
control row with status: "verified" means that the engine accepted the supplied digest and
repository, target-ref, tree, time, or run relationships required for that control. A caller that
can supply the request can still make those assertions; the enum does not identify or
authenticate the caller. The sealed bootstrap additionally checks the requested identities and
digests against the returned envelope, but republishes the accepted bytes unchanged. The
controls.semantic_evidence array similarly records each accepted envelope’s payload digest and
producer/input identity; it proves engine binding and interpretation, not who acquired the
inventory.
The provider lanes leave separate provider evidence: an App-owned Check Run on GitHub’s test merge, a protected GitLab policy-job result on a merge-train commit, or a dedicated Gitea-family review. GitHub’s Check Run and the Gitea-family review carry the staged summary and report digest; GitLab’s provider-visible evidence is the exact policy job’s outcome. The controller’s saved result binds the plan, execution constraint, and gate identity in every lane. When a report is present, it also binds the report digest. No provider signs or adds fields to the report. Moving the same report bytes away from that gate therefore loses the provider context; there is still no provider attestation inside the current report contract.
Sandbox provenance is separate again. The present writer reports self-asserted assurance,
local-process enforcement, and null verification. The sealed bootstrap requires that honest
projection. Runtime-closure validation, a cleared environment, fixed input, and a watchdog do
not satisfy the report schema’s provider-verified OCI or microVM mechanisms.
The machine contract is the current report schema, its readable example, and the corresponding canonical bytes. The test suite validates emitted bytes with an independent schema validator, checks the canonical example, and checks that the schema identifiers match the writer constants in the documentation contract test.
The wire is versioned by its own compatibility field, not by the engine release: 1
means frozen, additive within the major. A 1 report may gain optional fields as 1
rolls forward, and nothing a 1.0 consumer parsed ever changes meaning or disappears.
The promise is mechanical: the first frozen example is retained permanently beside the
rolling one, a contract test requires every later schema in the major to keep validating
it, and a second test holds the example the last release shipped to the same bar.
Reshaping past that promise mints 2, and that release is a major one. The record of
how the contract earned the freeze is in
A settled wire.
The external plan
A report retains a destination when its resolution delegates evidence to another layer: an external URL the engine does not judge, or a same-repository exact commit whose required objects are unavailable locally. The external plan is the pure derivation that turns one written report into the work another layer may do: which distinct delegated destinations this change introduced, which it removed, and where each one lives.
amiss external-plan --report report.json --format json
The command opens no repository and touches no network. It reads the report file,
verifies the payload against the digest the envelope records, and refuses anything less
than a complete report. The digest proves the payload is whole and untampered relative
to its own envelope, so corruption and truncation are refused; where the file came from
is the caller’s supply chain, as it is for every other input. The derivation is set-wise
per side: a destination counts as introduced when the
candidate references it and the base does not anywhere in the tree, and as removed in
the mirror case. A destination that only moved between documents is neither; it is
counted under retained_count and never listed, which keeps the plan proportional to
the change rather than to the corpus.
Each row carries the destination exactly as the report recorded it, after the format’s
own decoding, the address an evidence producer would request; its lowercased scheme; and the sorted
documents naming it. Unavailable exact history uses https, the only scheme accepted by the
same-repository forge grammar. A destination on a forge host the run can name, github.com,
gitlab.com, codeberg.org, bitbucket.org, or the report’s own declared host under its declared dialect,
also carries a repository object: host, dialect, owner, name, form, and opaque tail. The ordinary
forge grammars take owner and name from their leading path segments. Bitbucket Data Center instead
recognizes an optional installation context followed by the first projects or users repository route;
its project key or personal slug becomes the owner, and browse is the form. The destination still
retains the revision query verbatim. Tails stay unsplit where branch names may contain slashes, so
separating revision from path needs the other repository’s refs, and naming structure is not claiming the repository
exists; both belong to the verifying layer. The payload binds the report’s own payload_digest and echoes its
evaluation identities, and the plan envelope carries a digest of its own payload under
the plan schema identity. A producer that probes the introduced list, and any later
judgment over that evidence, can therefore join plan, report, and evidence on one
identity without re-reading the tree. The schema is
scanner-external-plan.schema.json,
and its example is derived from the report example by the same code path, checked in CI.
The plan states work; it performs none. Fetching stays outside the engine for the same reasons What Amiss is not gives for live URLs: a probe’s answer varies with the network’s mood, and a guessed pass looks exactly like a real one. What a producer observes comes back through the external assessment, and the composition with a checker that does fetch is one pipe, shown in Amiss and link checkers.
Exit 0 wrote the plan, human or JSON. Exit 2 means the input could not be trusted: unreadable, larger than a scanner report can be, not the scanner’s strict JSON, not a report envelope, a payload that fails its recorded digest, an incomplete report, or an eligible occurrence missing its destination, document, or required scheme. There is no exit 1, since a plan carries data and no verdict.
The external assessment
The external plan names work; the assessment judges what came back.
A producer probes the plan’s introduced destinations or asks a forge API about the shaped
ones, and writes its observations into an evidence file. Two producers ship in this
repository: the provider lanes verify shaped destinations through their own APIs, and
amiss-probe --plan plan.json probes the unshaped https ones, every URL and redirect hop
vetted and address-pinned before a byte leaves the process. Any other producer works too.
The engine then judges offline:
amiss external-assess --plan plan.json --evidence evidence.json --format json
Evidence carries observations, never verdicts. A probe row reports the final status or
the transport failure, exactly one of the two, the method that saw it, and where
redirects ended. It marks that destination as a permanent retarget only when every
observed hop used 301 or 308. A forge row reports what the API said: the repository’s
visibility first, then how the opaque tail resolved against its refs. The file binds the exact plan
by payload digest, and the discipline is strict in both directions: a row naming a
destination the plan did not introduce, repeating one, or binding another plan refuses
the whole run, while destinations the file never mentions simply stay unproven. The
schemas are
scanner-external-evidence.schema.json
and
scanner-external-assessment.schema.json,
and the assessment example is derived from the plan and evidence examples by the same
code path, checked in CI.
The judgment policy is fixed in the engine and deliberately conservative, because the
web’s refusals outnumber its deaths. A 404 or 410 refutes only when a GET confirmed it,
since servers drop HEAD requests they would answer. A 401, 403, 429, or LinkedIn’s 999
is a wall, not a grave: unproven. Transport failures, unfollowed redirects, and absent
evidence are unproven too, each with its reason named. On the forge side a missing
repository never refutes, since forges answer 404 for private repositories they will not
name; refutation needs a readable repository whose refs resolved and whose path or
revision then proved absent. Every redirect destination remains evidence, but only an
all-301/308 chain lands as a retarget suggestion on the row, never a finding or an
automatic edit. And reachable claims exactly what it says: something answered, not that
the content is still right.
Every verdict row echoes the plan’s document attribution, and the subject block binds report, plan, and evidence digests, so the same three inputs always reproduce the same assessment, digest included, and a lane can replay the whole chain from artifacts alone. Exit 0 wrote the assessment, refuted rows included. The command itself remains advisory; a consumer decides what those rows do. Human output shows up to ten permanent-retarget suggestions and names any overflow; JSON retains every row. Exit 2 means an input could not be trusted.
Provider plans expose that decision as external_policy. off makes no external API calls;
advisory, the default, retains and counts the assessment without changing the engine result;
and block-confirmed-refutations changes a passing provider result to block only when the
retained assessment contains at least one refuted row. An incomplete assessment, unproven
row, authentication or rate-limit wall, private-repository 404, transport failure, missing
evidence, or reachable row never changes the engine result. The blocking mode is an opt-in pilot:
review a lane’s retained advisory evidence over time before enabling it. Arbitrary HTTPS remains
the separate advisory experiment described in Continuous integration.
Provider lanes retain the canonical plan, provider evidence, and assessment beside the exact provider-bound report before the final provider refresh and publication stage. The policy is part of the controller plan digest. The published assessment digest and artifact locator therefore name one frozen chain and one frozen decision. A lost provider reply or service restart verifies and reuses those bytes without another API probe; incomplete verification is retained as incomplete rather than reconstructed later. If the final refresh finds a changed head or gate, the staged result is superseded even when the retained assessment had refuted a destination. Authorization, expiry, and capacity are defined in Retained provider artifacts.
Trusted semantic evidence
Some documentation identities exist only after another tool has done work the repository tree cannot represent. A Sphinx inventory maps foreign object names to published URIs. A completed site build owns generated routes, anchors, redirects, versions, locales, and navigation. Amiss does not execute either producer inside the engine, but those different producers need the same trust and replay boundary.
The semantic-evidence envelope is that boundary. Its payload binds:
- the scanner candidate-identity digest, which already covers repository identity, refs, both snapshot materializations, and forge semantics;
- an optional source-report payload digest when evidence was derived after a scan;
- the producer kind, stable implementation identity, version, independently selected semantic context, and the kind-defined digest of all inventories or completed-build input;
- whether the producer completed that exact input;
- at most 100,000 observation objects, sorted by canonical JSON and unique.
The envelope carries the domain-separated payload digest and is limited to 16 MiB. Its strict reader refuses malformed JSON, unknown envelope fields, invalid identities, duplicate or unsorted observations, oversized input, and a mismatched digest. Construction sorts observations once so a filesystem, inventory, or build traversal order cannot change the evidence identity.
Observation vocabularies do not share a synthetic universal graph. An Intersphinx producer needs
domain, role, object name, inventory identity, and URI. A site-output producer needs routes,
anchors, redirects, navigation edges, and source attribution. The envelope requires only a bounded
kind on every observation; a compiled consumer owns the closed grammar and judgment for kinds it
recognizes. An unknown kind therefore remains inert data. Parsing the envelope never turns it into
a pass, a block, or a suppression.
This contract authenticates nothing by itself. Provider-enforced use must acquire it outside the repository. Each sealed value carries an independently planned expected context digest; the engine requires the producer’s context digest to match before interpreting any observation. A repository file, cache entry, or self-asserted local producer cannot promote its own observations to authority. Partial evidence may prove a fact positively only where a later kind contract permits it; absence can carry meaning only for a declared complete set over the exact input digest.
The first compiled consumer accepts one complete sphinx-inventory-set producer at version 1,
with no source-report binding. A sphinx-label observation carries an inventory identity, a
Docutils-normalized label, and one syntactically valid absolute HTTP(S) destination. The engine uses
that table only after every envelope in the controls request matches the exact candidate identity.
One unique prefixless :ref: label resolves through the inventory; repeated labels across
inventories remain ambiguous, colon-prefixed names remain unsupported, and local declarations keep
precedence. Missing evidence, an incomplete producer, another producer version, a stale candidate
binding, or an invalid observation can never clear a missing label.
The second compiled consumer accepts at most one complete site-build producer at version
0.5.1. A site-route observation carries one exact absolute-path URI, one repository source
document, and a byte-sorted unique set of decoded anchor identities. Routes exclude authority,
query, and fragment components; sources obey the repository-path grammar; anchors and their
aggregate count are bounded. On the candidate side only, an exact route resolves to its scanned
structured source. A nonempty fragment first matches a published id or legacy <a name> anchor
verbatim, then by percent-decoded identity; ASCII-case-insensitive top identifies the page top.
This preserves a literal percent sign in a valid anchor without making malformed escapes resolve by
guesswork. A site-generated-route carries the same route and anchors plus a required nullable
source. A repository path is attribution for generated output, not its target body, and must remain
an exact ordinary candidate blob. null says the completed page has no repository attribution; it
does not invent a virtual source. Either form resolves as external/site-build. A missing source
field or malformed attribution rejects the complete evidence. Query text remains identity data. A
route absent from the evidence, an absent anchor, an unsuitable attributed source, and image use
remain unsupported rather than being guessed into either a pass or a failure.
A site-redirect observation maps one exact redirect route and its repository routing source to
its exact terminal route, not an intermediate hop. The destination may carry a fragment but no
query. It resolves only when that terminal route has one uniquely claimed source-backed or
generated page and the effective fragment is in its anchor set. Following the
HTTP Location rule, an absent
destination fragment inherits the authored fragment, a nonempty one replaces it, and an empty #
suppresses inheritance. Self-redirects and fragments containing raw control characters make the
evidence invalid.
Conflicting route owners and redirects ending at a missing, ambiguous, nonterminal, or anchor-less
target do not resolve and each produce one site-build-defect whose fact retains the exact route,
claim identity, reason, and every available routing source. A conflict containing only unattributed
generated pages has an empty source set and no location path rather than a fabricated one. A
site-navigation observation adds one source root, its navigation manifest, rendered entrypoint
routes, and the byte-sorted unique source set reachable through the completed link graph; that set
may be empty.
Every entrypoint must be a unique page route, every reachable source must own a repository-backed
route, and all named sources must remain beneath the declared root. Only then does
unlinked-document mean a scanned structured source inside that root which is neither the manifest
nor reachable. Without this observation the engine makes no navigation claim. The base side never
consumes candidate build output.
The public check command may read one candidate-independent
semantic template.
It has the producer, completeness, and observation fields above but no subject field. After the
scanner resolves the exact commit tree or pins the staged-index projection, it binds the template
to that candidate and passes the resulting envelope through the same compiled consumers. The file
is repository-user-selected and its context is not independently planned, so the report remains
self-asserted; this local convenience path cannot become provider authority.
The offline amiss record-set authoring form accepts one closed
normalized record-set input
and emits that template shape with the fixed record-set@1 producer contract. Its rows pass the
same decoder the scanner uses: keys are sorted and unique, and keys and display values are
nonempty, control-free, and bounded. The specialist still owns extraction, its stable identity,
both supplied digests, and whether the set is complete. Amiss neither executes a language tool nor
recomputes or authenticates those claims; the command only validates and canonicalizes their
transport. Its output therefore remains self-asserted when supplied to check.
The separate unpublished amiss-rust-public-api producer is one such specialist, but it writes the
checked template directly so the same bytes can be a planned workflow artifact. It accepts exactly
one bounded producer context and one bounded Rustdoc JSON file:
amiss-rust-public-api --context rust-public-api-context.json \
--rustdoc target/doc/example.json > amiss/semantic-template.json
The producer currently consumes format 61. The example was measured with
nightly-2026-08-28; the workspace’s pinned Rustdoc emits format 60 and is deliberately refused.
Generate the input with the exact separately pinned toolchain named by the producer context, for
example:
cargo +nightly-2026-08-28 rustdoc -p example --lib -- \
-Z unstable-options --output-format json
Do not edit the format number in an artifact.
The context is strict JSON with this closed shape:
{
"cfg": [],
"compiler": "rustc 1.100.0-nightly (e457a7b0d 2026-08-27)",
"dependencies_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"features": ["default"],
"name": "rust/example/local-function-declarations",
"package": "example",
"rustdoc_format": 61,
"schema": "amiss/rust-public-api-context",
"target": "example",
"target_triple": "x86_64-unknown-linux-gnu"
}
The feature and cfg sets are byte-sorted and unique. The set name ends in
/local-function-declarations, so its completeness cannot be mistaken for the entire Rust item
surface or dependency-owned re-exports. Compiler, package, target, features, cfg, and the
operator-computed dependency/configuration digest all enter the context digest. target is the
Rust crate target name recorded on the root module, after Cargo’s crate-name normalization or any
explicit target rename; it and the target triple are checked exactly against the parsed input.
Package and crate target names are intentionally separate because Cargo permits them to differ.
Rustdoc carries no Cargo package identity, so package is context-bound while the independently
declared target is the artifact-side check. The active producer accepts only the one Rustdoc format
represented by its pinned maintained adapter. It does not start Cargo, rustdoc, or another process.
Completeness applies only to that exact feature, cfg, target, and dependency context. Two
configurations are independent record sets and need distinct names when supplied to one scan;
neither the producer nor the scanner silently unions or intersects them. A matrix-wide API requires
a separately named producer contract that declares union or intersection and resolves keys whose
values differ between configurations.
The complete set contains public free functions defined by the root crate, public functions from
inherent implementations of its public structs, enums, and unions, and functions declared by its
public traits. Keys use the disjoint fn/, inherent-fn/, and trait-fn/ namespaces followed by
the adapter-owned public import path; an associated-function path appends its method name to its
owner path. A local re-export therefore owns its alias rather than the definition’s private path.
Trait-implementation bodies and functions defined by a dependency are outside this explicitly
scoped set; those need separate impl-relation or dependency inputs before they can be called
complete. Specialized inherent implementations that expose the same public owner and function name
are refused as ambiguous; neither Rustdoc numeric IDs nor rendered syntax are promoted into a
false stable identity.
Each value is a one-line canonical comparison string made from the adapter signature and that
exact path. It is not Rust source or downstream call syntax. Rustdoc removes raw-identifier markers
from canonical paths, and the adapter may retain crate-relative type paths and crate-authored
parameter names. ASCII whitespace is collapsed so ordinary multiline where predicates remain
representable. Malformed input, an unsupported format, a crate-target or target-triple mismatch,
an ambiguous path, a duplicate row, more than 100,000 rows, a context above 64 KiB, or Rustdoc JSON
above 32 MiB refuses the output instead of weakening completeness. Raw Rustdoc numeric IDs appear
in neither keys nor values.
The scanner needs no Rust-specific control to attach one of those values to visible documentation.
This projection assertion selects the producer row for example::check:
{
"document": "docs/api.md",
"name": "check-signature",
"projection": "code-text-v1",
"sink": "previous-code",
"source": {
"kind": "record-value",
"set": "rust/example/local-function-declarations",
"key": "fn/example::check"
}
}
The named document owns the ordinary projection sink immediately after the visible value:
```text
pub fn example::check() -> bool
```
[amiss:check-signature]: <amiss:projection>
The set is the exact context name and the key is a producer-owned stable record identity. A
changed declaration becomes projection-drift; a missing row is proven absent only when the
producer says this scoped set is complete. To show the whole scoped API instead, use a
record-set source with the same set and sorted-rows-v1. That projection compares every
display value in byte-sorted order and refuses a partial set before judging visible rows. These are
the same generic projection controls used by any record producer; the scanner does not parse Rust
syntax or introduce a symbol-specific finding.
The sealed controls request remains the provider-authenticated intake. A controller plan may hold candidate-independent templates such as an Intersphinx inventory set. A trusted acquisition may instead return exact candidate-independent template bytes beside the repository and action roots. The frozen plan names each acquisition identity, producer kind, producer identity, version, and expected context exactly once. While building the sealed job, the controller strictly parses both sets, requires every acquired template to match its planned identity and producer context, binds the exact candidate and no source report itself, applies their one combined count limit, orders them by payload digest, and rejects collisions. The engine repeats the context comparison from the sealed pair before consuming the evidence. A missing, extra, malformed, stale, wrong-context, duplicate, or oversized acquired set is runtime tampering, not absent evidence.
The same plan contract can freeze each provider workflow artifact as an acquisition source. Its provider and repository, workflow identity, event, artifact name, sole payload member, producer contract, and separate archive and payload byte ceilings are all plan-digest inputs. The only candidate rule is exact equality with the authenticated provider run’s candidate commit; it is not repository-selectable policy. This is a controller trust primitive, not a claim that every provider can fetch such artifacts: a lane must separately expose operator configuration and implement the provider-specific authenticated acquisition before the expectation is usable.
Job construction also produces one canonical semantic-input audit value, capped by
SEMANTIC_INPUT_ARTIFACT_BYTES before base64 allocation. In payload-digest order it holds each
exact template byte stream, the exact canonical bound envelope, its acquisition identity when
acquired, and SHA-256 and semantic payload digests. Reconstructing candidate binding therefore does
not depend on mutable producer output. The bootstrap job exposes this value separately from the
frozen engine report.
Provider lanes retain that value beside an accepted report and publish its digest and authenticated locator, so audit does not depend on a producer workflow’s shorter artifact lifetime.
A successful report projects the accepted envelopes’ payload and producer/input identities under
controls.semantic_evidence; the sealed bootstrap checks that projection against the request. An
inventory-backed external destination is already resolved evidence, so the external-probe plan does
not schedule it for a second network judgment.
Provider services can produce that set from controller-configured local objects.inv files. The
producer bounds both compressed and decoded bytes before the pinned sphinx_inv parser sees the
body, selects only std:label records, resolves their locations beneath an operator-owned HTTP(S)
base under the engine’s URI grammar, and binds the exact inventory bytes, identity, and base into
its input digest. The resulting template is held once in the controller plan and receives the exact
candidate identity only while the sealed job is built. Fetching and caching remain deployment
concerns outside both the engine and the repository being checked; provider
configuration accepts only the bounded local result.
The controller’s first site-build producer consumes an operator-owned identity containing the
exact repository book.toml path, publication prefix, optional locale, and optional version, plus
the exact post-preprocessor
mdBook renderer context from
mdBook 0.5.4 and a caller-opened completed HTML output directory. It uses the renderer’s path
and source_path, rather than reconstructing routes from SUMMARY.md, so the built page and its
original repository source, when one exists, remain distinct after preprocessing. The first
rendered chapter’s independent index.html copy is read separately. Every other route follows the
HTML renderer’s .html path rule beneath one trusted publication prefix, with URI path segments
encoded from the actual output names.
The producer reads only the rendered pages named by the context, with one 16 MiB context
ceiling and one 16 MiB aggregate HTML ceiling. A no-follow directory capability bounds every page
read. A WHATWG tokenizer extracts decoded id values and hyperlink destinations from the completed
HTML. The producer honors the document’s first base URL when usable, retains only links to another
proved page under the same publication origin, and walks that bounded graph from the independent
index.html entrypoint. It emits the configured source root, its SUMMARY.md manifest, the
entrypoint, and the sorted set of reachable repository sources beside the sorted page anchors, then
binds the resolved renderer configuration, every exact page digest, and the navigation result into
the input digest. A plan independently freezes the site identity; evidence from another
configuration path, publication prefix, locale, or version is refused. The wrong mdBook version,
no HTML renderer, an escaping or non-text output or source path, a source without an output path,
duplicate route ownership, an unreadable page, an unrepresentable anchor or link, or an oversized
graph refuses the complete set. A rendered chapter with an output path and no source_path instead
becomes an explicitly unattributed generated route. Theme, preprocessor, and configuration effects
are therefore observed in their finished bytes without running any of them inside Amiss.
A trusted candidate acquisition may call this producer after an operator-owned build and return the exact candidate-independent template bytes under its planned acquisition identity. The controller, not the producer, binds the candidate. The GitHub provider lane can read an explicitly configured workflow artifact through the App installation API; the Gitea-family and GitLab lanes still expose no such source. The controller neither starts mdBook nor treats repository output or cache state as authority. The acquisition boundary keeps candidate output out of a startup plan and keeps the scanner from searching the repository for evidence.
Site evidence may be complete while its route graph is internally defective. Those defects become
ordinary site-build-defect findings: a broken redirect records its source, route, destination,
claim digest, and reason, while duplicate ownership records the route plus the sorted distinct
claim digests and repository sources. Generated routes have no repository source, so a duplicate
route’s source list may be empty even when its claim list is not.
The bound-envelope schema and checked example are
scanner-semantic-evidence.schema.json
and
scanner-semantic-evidence.json.
The candidate-free input has its own
scanner-semantic-template.schema.json
and
scanner-semantic-template.json.
The record-set authoring input is checked by
scanner-record-set-input.schema.json
and its
scanner-record-set-input.json
example is required to reproduce the semantic-template example through the real writer.
The engine still executes no producer and treats no repository-controlled evidence as authority.
Publication audits
A repository scan proves facts about one repository candidate. It does not prove that a completed site was deployed, that a public channel serves that site, or that the site describes the intended product release. Those are separate publication facts acquired after the scan and often after a deployment.
The publication plan is the operator-owned half of that later audit. It binds one intended relation between:
- the payload digest of the scanner report the operator accepted;
- the docs repository, commit, tree, and full scanner candidate identity;
- the deployment provider instance, environment, channel, and canonical HTTPS URL;
- the immutable completed-site artifact and the site producer input digest derived from it;
- the exact product resource URI and digest;
- the independently selected deployment evidence producer and its context; and
- the operator’s named docs-to-product relation rule and its context digest.
Channel names such as stable, latest, or next are opaque policy labels. Amiss does not sort
versions, compare timestamps, select a tag, or infer that two similarly named resources belong
together. The product and site URI spellings are identifiers; their SHA-256 digests establish the
exact bytes. A mutable URL or tag without its resource digest is not a publication identity.
The plan is a closed, 64 KiB, digest-bound JSON document. Git object IDs must match their declared object format. The canonical public URL is HTTPS without a query or fragment. Resource URIs use one exact lowercase absolute scheme and carry no fragment. Provider, channel, environment, producer, and relation identities use the bounded artifact-identity grammar. Unknown fields, invalid values, mixed Git formats, oversized input, or a changed payload refuse the whole document.
The matching publication evidence is a provider-normalized receipt for one successful terminal deployment. It binds the exact plan payload digest and independently repeats the observed docs candidate, target, completed-site artifact, and product resource. It also records:
- the selected evidence producer identity, version, and context digest;
- the provider deployment record as an immutable URI-and-digest resource;
- the exact workflow or deployment definition as another immutable resource; and
- the one-based provider run attempt that distinguishes reruns.
Only succeeded is a receipt outcome. A failed, cancelled, pending, partial, or unauthenticated
provider response cannot be encoded as successful publication evidence. Repeating the publication
facts is intentional: the later offline assessment compares independently acquired facts with the
plan rather than trusting a producer that merely echoes a plan digest. The provider adapter must
authenticate its API, attestation, or receipt before normalization; engine crates perform no
network or signature work.
The offline assessment has three outcomes. matched means the bound receipt came from the planned
producer context and its docs, target, site, and product facts all equal the plan. refuted means
that trusted receipt disagrees with at least one of those four fact groups, each named in a sorted
reason set. unproven means there was no receipt, it answered another plan, or it came from another
producer context. Foreign or absent evidence can never refute a plan.
The assessment binds the accepted report, plan, optional evidence, and exact evaluator binary by
digest. A missing receipt is represented by a null evidence digest and the single
evidence-absent reason. Malformed, failed, pending, partial, or mutable-only provider material
never becomes a typed successful receipt; callers retain that acquisition failure and assess the
plan as unproven instead of manufacturing a negative fact.
The outer payload digests are integrity checks, not signatures or authority claims. Repository content must not choose the plan, producer context, relation rule, or credentials. The controller can validate a complete audit before retention: the plan’s report digest and docs repository, commit, tree, and candidate identity must describe the supplied complete scanner report, and the assessment must replay exactly from the supplied plan and optional evidence. Its artifact store can then retain and reopen the exact report, plan, optional evidence, assessment, digest set, and verdict as one immutable evaluation-bound record. The authenticated component routes are described under Retained provider artifacts. No controller lane acquires, stages, or publishes this chain yet, no scanner command consumes it, and a plan alone never says a deployment happened.
The checked public contracts are
publication-plan.schema.json,
with a matching
publication-plan.json
example, and
publication-evidence.schema.json,
with its
publication-evidence.json
example, and
publication-assessment.schema.json,
with its replayed
publication-assessment.json
example. The strict readers, writers, and pure assessment live in amiss-wire; provider-specific
payloads never enter the engine contract.
Locale coverage audits
A repository scan can bind the exact docs candidate that approved a later locale audit, but it cannot infer which pages a documentation generator considers equivalent across locales. Routes are publication outputs; they are not stable page identities. The locale coverage plan therefore names one independently selected inventory producer and treats every page key it will emit as opaque. It can also select the same immutable product-resource identity used by publication audits, without turning a locale version label into a release heuristic.
The plan binds:
- the accepted scanner report payload digest;
- the docs repository, commit, tree, and full candidate identity;
- one site, source locale, target locale, channel, and optional version;
- an optional exact product resource URI and digest that both locale inventories must identify;
- the inventory producer identity, version, and plan-owned context digest; and
- the operator’s coverage-policy identity, context digest, required-page rule, and authorized fallback classes and page scopes, plus whether exact target lineage is required.
Site and channel use the artifact-identity grammar. Locale and version labels use a broader bounded
identity grammar so a producer can retain spellings such as de-DE; Amiss compares them exactly.
It does not validate BCP 47, order versions, or infer fallback from a locale hierarchy. Source and
target locale must differ. The opaque version remains scope metadata and never substitutes for an
immutable product resource.
Page selectors have two closed forms. The coverage rule uses one selector: all-source means that
every key in the future complete source inventory is required in the target inventory; named
carries one nonempty, byte-sorted, duplicate-free set and makes only those source keys required.
Other source keys remain optional, while target keys outside the source inventory can still be
reported as orphaned. A page key is nonempty, control-free, and at most 4,096 UTF-8 bytes. It is a
generator-owned identity such as a canonical docname, never a path or route guessed by Amiss.
The policy also carries a byte-sorted set of fallback rules, unique by opaque class. Each class has
an all-source or named page selector describing exactly where that producer-defined fallback
mode is allowed. An empty set forbids all fallbacks. The plan chooses authorization; it does not
prove that a target page really came from a source resource.
require_target_lineage independently chooses whether every observed target-owned page must carry
exact source lineage. False preserves a coverage-and-fallback-only audit and ignores any supplied
target lineage. True makes missing lineage unproven and a mismatched lineage digest an exact
refutation. Fallback pages are excluded because their F02 origin already binds the exact current
source resource.
The optional plan product reuses publication’s PublicationResource directly: one absolute URI
identifies the resource and its SHA-256 digest identifies the exact bytes. Null selects no product
alignment policy. Amiss does not derive this value from the channel, scope version, tag, timestamp,
or similarly spelled URL.
The contract intentionally contains no translation verdict or timestamp. A target page with the same key can prove structural coverage only. Exact lineage can prove which normalized source resource a target was based on; it cannot prove that the page was translated correctly, remains semantically equivalent, or required a change. This contract uses a digest because the source inventory supplies an exactly comparable digest. It does not accept an opaque revision without a matching source-side revision identity.
The plan is a closed, 64 KiB, digest-bound JSON document. Unknown fields, malformed identities, mixed Git object formats, repeated or unsorted named keys, oversized text, or a changed payload refuse the whole document. The outer digest establishes integrity, not authority: repository content must not choose the producer context or operator policy.
The matching evidence contract repeats the plan digest, docs candidate, locale scope, and producer context, then carries separate source and target inventories. Each inventory has its own input digest, nullable independently observed product resource, completeness bit, and byte-sorted map from page key to exact resource digest. The producer context defines which normalized bytes those digests identify. A null product says the authenticated producer could not establish that side’s release identity; it does not assert a mismatch.
Every observed target page also carries one closed origin. target-resource says the producer
observed a target-owned resource and carries either its exact based_on_source_digest or null when
the producer has no exact lineage assertion. fallback names an opaque producer-declared class and
the exact source resource digest from which the fallback was obtained. A separate fallback list
would make omission indistinguishable from target ownership. Requiring an origin on every target
instead forces one explicit producer claim; authority still comes from authenticated acquisition
outside the engine.
Completeness belongs to each side independently. A false value preserves the pages the producer did observe, but absence from that inventory is not evidence that a page is absent from the locale. Product availability is independent of page completeness: a complete page set may still have an unproven product identity, and a partial page set may carry an exact product receipt. The two inventories may carry at most 100,000 page rows combined inside one 16 MiB document. Page keys are unique within each side; malformed digests, duplicate or unsorted keys, unknown fields, and a changed payload refuse the whole receipt.
The engine reader establishes shape and integrity, not producer authority. Repeating a plan digest does not establish that the independently repeated facts match that plan. The pure offline assessment first requires the exact plan digest and selected producer. Foreign evidence remains unproven; correctly bound docs or scope disagreement refutes the plan without comparing the foreign inventories. Evidence acquisition outside the engine must therefore authenticate the selected producer, while the selected producer context defines the meaning of its origin classification.
For matching facts, every page row in the assessment is proved by presence on one side and complete
absence on the other. A complete target can therefore prove that an observed required source page
is missing even when the source inventory is partial. A complete source can likewise prove that an
observed target page is orphaned when the target inventory is partial. Such a refutation is valid,
but coverage.complete: false says the reported rows may be only a lower bound. The assessment
matches only when its policy-scoped missing, orphan, named-source, and fallback checks are
exhaustive and clean. A named policy can be exhaustive without an unrelated full source inventory
when every named requirement and every target key has explicit source presence; all-source
always requires a complete source set. Page resource digest differences remain deliberately inert.
Every fallback assessment row retains its page key and class. allowed means one plan rule admits
that class and page and the declared source digest equals the observed source resource.
unauthorized and source-mismatch are exact refutations. If the source page is absent from a
partial source inventory, source-unproven keeps the whole result unproven; absence cannot become a
digest mismatch until the source inventory is complete. Allowed fallback is not a translation or
freshness verdict: it proves only the exact policy and provenance relation named by the contracts.
When target lineage is required, the assessment also checks every observed target-owned page whose
source row is available, including pages outside a named required-coverage set. current means its
declared based-on digest equals that current source resource; stale is the exact unequal case;
unproven means the producer supplied no exact based-on digest. A target absent from a complete
source set is already orphaned. A target absent from a partial source set remains covered by the
existing source-incomplete result, so the evaluator does not manufacture a lineage row without a
current source value to compare.
When the plan selects a product, the assessment compares each inventory’s product independently
with that exact planned URI and digest. The source and target fields reuse the ordinary
matched/refuted/unproven verdict vocabulary. Matched means exact equality; refuted means a
different immutable resource was observed; unproven means that inventory supplied null. A product
mismatch is an exact refutation even if the other side is unavailable. If the plan product is null,
both supplied product receipts are deliberately ignored and the assessment product result is null.
This relation proves release identity only, not translation quality or deployment success; the
producer or future publication lane must authenticate how each inventory acquired it.
The assessment binds the exact evaluator, accepted report, plan, and optional evidence payload. Its
three byte-sorted key sets name policy keys absent from source, required source keys absent from
target, and target keys absent from source. A fourth byte-sorted set records every assessed
fallback, and a fifth records every assessed target lineage. The document is bounded to 16 MiB and
200,000 rows across those sets. A separate nullable product result records the source and target
resource verdicts. coverage.complete remains strictly about the page comparison, so the overall
assessment can be unproven solely because a product receipt is missing while coverage is complete.
Missing, unbound, wrong-producer, or otherwise insufficient evidence is unproven rather than clean.
The command and controller intake are not built yet.
The checked public contracts are
locale-coverage-plan.schema.json,
with the matching
locale-coverage-plan.json
example, and
locale-coverage-evidence.schema.json,
with its
locale-coverage-evidence.json
example, and
locale-coverage-assessment.schema.json,
with its replayable
locale-coverage-assessment.json
example. Their strict readers and writers live in amiss-wire; generator-specific parsing and
authentication stay outside the engine crates.
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.
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.
Projection work has separate snapshot totals for admitted assertions, selected source bytes, records prepared for comparison, canonical projected bytes, and diagnostic preview bytes. Repeated assertions spend these totals even when their source blob is cached: caching avoids a second Git read, but it does not make the projection comparison free. Preview bytes are charged before they are copied into a finding; rows omitted by the fixed preview bound cost no report memory and are represented by their exact omitted count.
| Report 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 |
projection-assertions-per-snapshot | 10,000 |
aggregate-projection-selected-bytes-per-snapshot | 67,108,864 |
projection-records-compared-per-snapshot | 200,000 |
aggregate-projection-projected-bytes-per-snapshot | 67,108,864 |
aggregate-projection-preview-bytes-per-snapshot | 16,777,216 |
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 |
declared-labels-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. A process-level breach may
prevent a report rather than produce RESOURCE_LIMIT_EXCEEDED.
For measured counters, the charging rules keep every reported number reconstructible. Counters stop exactly one past the limit. Per-item byte limits report the declared size of the item. A snapshot-wide total reports the running total plus the first item that crossed it, and an item already rejected by its own per-item limit is never added to the total.
A crossing, as the report records it:
{
"code": "RESOURCE_LIMIT_EXCEEDED",
"phase": "git",
"resource": "raw-path-bytes",
"configured_limit": 4096,
"observed_lower_bound": 5008
}
Both numbers travel with the error, so the reader knows how far past the ceiling the input went without rerunning anything.
Refusals follow one rule: when the 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 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 acceptedINVALID_EVENT: the declared repository, ref, or default-branch identity is not in canonical form; pass a lowercase owner and name and full refs/heads/ referencesINVALID_PROFILE: the profile is not observe, enforce-introduced, or enforceREQUEST_UNREADABLE: the machine evaluation request bytes could not be read; nothing was evaluatedCONFIGURATION_INVALID: a policy or control input violates its schema; one unknown field or malformed value makes the whole file invalid rather than partly honoredDUPLICATE_JSON_KEY: a JSON input repeats an object key; strict parsing refuses the file instead of choosing one of the valuesINVALID_UTF8: a JSON input carries bytes that are not UTF-8INVALID_JSON: an input that must be JSON does not parse as strict JSONUNKNOWN_SCHEMA: a JSON input declares a schema identifier this engine does not recognizeUNKNOWN_FIELD: a JSON input carries a field its closed schema does not define; unknown fields refuse rather than pass through unreadNONCANONICAL_ARRAY: a JSON input array violates its required canonical ordering or uniquenessDIGEST_MISMATCH: a digest carried by an input does not match the bytes it names; the input is stale or alteredCONTROL_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 incompleteEXCEPTION_OVERLAP: accepted exception items select the same finding more than once; overlap ends evaluation incomplete instead of double-suppressingUNSUPPORTED_CAPABILITY: a candidate document declares a reserved amiss: capability this engine does not implement; the run ends incomplete rather than guessing at the claimGIT_REPOSITORY_UNAVAILABLE: the –repo path does not open as a Git repository of the declared object formatGIT_OBJECT_MISSING: a commit, tree, or blob the run needs is absent from the object store; fetch full history or name commits the store holdsGIT_OBJECT_WRONG_KIND: a Git object is not the kind its use requires, as when a named commit resolves to another typeGIT_OBJECT_UNREADABLE: a Git object exists but its bytes cannot be decodedGIT_INDEX_INVALID: the staged index file does not parse under the index grammarGIT_INDEX_UNMERGED: the index holds unmerged conflict entries, so no single staged state exists; finish or abort the merge before checking the indexGIT_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 indexGIT_SNAPSHOT_CHANGED: the staged index changed while the run was reading it; rerun when the repository is quietUNREPRESENTABLE_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 hexDOCUMENT_INVALID: a discovered document’s bytes cannot be decoded as its format requires; the run refuses instead of skipping the file and passingPARSER_ERROR: the pinned parser failed on a document; the document is named and the run is incomplete rather than the file silently droppedPARSER_PANIC: the pinned parser panicked on a document; the panic is caught and reported, and the run is incompleteINVALID_SOURCE_SPAN: the parser returned a node whose byte span does not address the document; the parse is not trustedRESOLUTION_ERROR: reference resolution failed internally; the run ends incomplete rather than reporting around the gapRESOURCE_LIMIT_EXCEEDED: a named resource crossed its ceiling; the row carries the resource, the configured limit, and the observed lower boundOUTPUT_LIMIT_EXCEEDED: the serialized report would cross the machine-json-bytes ceiling; the run ends incomplete instead of shortening the findingsTOO_MANY_ERRORS: more distinct analysis errors accumulated than the retention ceiling; the lowest-keyed rows are kept and this sentinel stands for the restREPORT_CONSTRUCTION_FAILED: the report could not be constructed or emitted; the run has no trustworthy outputSANDBOX_VIOLATION: the run breached its sandbox descriptor; the result is not trustworthyTRUSTED_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 clockINTERNAL_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 check both by comparing the tree and by scanning a read-only repository. Attempts to make it read outside the repository run into the never-follow-links rule described in Snapshots.
Parsers are the biggest attack surface and receive fuzz targets and pinned conformance
corpora. Document byte admission is charged before parsing. Parser node and nesting totals,
however, are measured and charged only after the grammar returns; they are output budgets,
not a general CPU deadline inside the parser. The order is explicit in the
scan pipeline.
One budget does act inside the parse: every candidate close of an MDX code region charges
the accumulated region against the aggregate-embedded-code-evaluation-bytes-per-snapshot
ceiling before the lexical scan reads it, which bounds the one measured quadratic case.
The history of that case is in the
corpus notes.
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 makes conventional source release tags usable by delegating to the same version’s immutable
action/vX.Y.Z runtime. That immutable second ref is part of a source-tag or source-commit pin; users that require one complete tree can pin the generated runtime tag or commit directly. The runtime is a GitHub convenience event wrapper. It verifies
the selected engine’s digest against the release manifest carried in the same action tree,
then launches the engine directly. That detects an inconsistent tree, but the manifest is
not an independently acquired trust anchor, and this lane does not use bootstrap’s
supervisor; it enforces its own wall-clock watchdog, 120 seconds unless the workflow
sets the watchdog-seconds input, and a scan that outlives the window ends with no
result.
The manifest’s build-source host is supplied explicitly and its repository identity is
forge-neutral, as pinned by the
release validation tests; that prevents a
format-level github.com assumption but does not authenticate the supplied identity.
The separately executable
amiss-bootstrap
implements the stronger local handoff. It bounded-captures a canonical request triplet for
commit-pair materialization; requires a complete repository, URL-dialect, candidate-ref,
target-ref, and default-ref identity; matches the embedded execution constraint and trusted-time
provider/run tuple; verifies that both commit objects were acquired before launch; and validates
the action tree and runtime closure. It then starts only the verified engine, in the supplied
repository, with a cleared environment and one private argument. A magic value, three bounded
lengths, and the exact request bytes travel in evaluation/snapshot/controls order over stdin.
Arbitrary engine arguments are not part of this path.
After the run, bootstrap acceptance rejects an unavailable hybrid and binds the engine, profile,
commits, candidate and protected target refs, and candidate identity recomputed from the report.
It requires exact organization-floor, debt-snapshot, and waiver-bundle presence, digest, and
trust source; binds the provider run and trusted instant; and checks the execution-constraint and
trusted-time digests against their recomputed semantics. Constraint trust source is bound too.
Acceptance also requires the report to retain self-asserted sandbox assurance, local-process
enforcement, and no sandbox verification. Clearing the environment,
fixing the executable and input, validating runtime closure, and enforcing the 120-second
watchdog are meaningful controls; they are not an OCI sandbox or microVM and must not be
reported as a provider-verified sandbox. The accepted engine envelope is republished unchanged,
so it does not gain an authenticated signature merely by passing through bootstrap.
Provider authentication belongs outside both executables. The
controller/ crates keep the raw
delivery untrusted until a configured verifier accepts it, keeps provider and storage
dependencies out of the scanner, and stops when ownership cannot be proven.
Controller delivery defines the neutral record, heartbeat, race, and retry
rules.
The source-built provider services are the concrete independent lanes. Their bounded plaintext listeners must sit behind an operator-controlled TLS terminator that also bounds connection concurrency and header, body, idle, and slow-body time. GitHub, Gitea, and Forgejo authenticate the exact webhook body before saving it, acknowledge only durable input, and authenticate the saved bytes again in the worker. GitLab instead authenticates the policy job’s short-lived OIDC token and keeps the request synchronous so only the exact passing result makes that protected job succeed. Each endpoint takes a configured in-process permit before reading the body and holds it through durable admission or synchronous evaluation. That cap does not replace the public connection and slow-client limits at the TLS edge.
Each adapter refreshes the repository, change, commits, trees, and protected merge rule through a controller-owned credential. GitHub requires a strict required check bound to its App and writes the Check Run on the test merge. GitLab requires an enforced merge train and independently owned pipeline execution policy, binds the job’s policy origin and runner, and uses the policy job’s result as evidence. Gitea and Forgejo require one approval restricted to the service’s dedicated reviewer and write the final review through that account. A missing, weakened, bypassable, or changed rule stays fail closed.
All lanes acquire exact SHA-1 repository and action commits through Git protocol v2, with fixed pack, object, inflated-byte, resolved-byte, delta-depth, and indexing-thread limits. They invoke the supervised bootstrap and refresh provider state again before accepting or publishing the result. Closure, changed head or gate, removed authorization, missing output, timeout, runtime tampering, or a wrong identity stays fail closed. The pinned action repository must be on the same provider instance.
The runner independently reopens the acquired repositories and checks the exact commit-tree roots. It derives the sealed job, matches the bootstrap to the pinned execution constraint, clears the child environment and standard streams, retains both bounded output handles, and uses ProcessKit’s cross-platform process-tree boundary. Every terminal path hard-kills and drains the group before output is accepted. Lease loss cancels the same tree. These rules cover ordinary process and ownership races; they do not promise that a host kernel operation can be interrupted if that operation itself never returns.
The provider API credential, webhook key ring or OIDC keys, artifact bearer token, execution constraint, optional controls, bootstrap, TLS terminator, scratch directory, raw inbox where used, delivery ledger, and artifact root are trust roots. Provider and repository administrators who can change the protected merge rule, integration or policy owners, reviewer-account owners, key issuers, and configured bypass actors are also inside the boundary. Repository bytes are not. A deployment is only as independent as its host and those operator-controlled inputs. Self-hosted instances must expose the exact APIs required by their lane and a certificate chain accepted by the Rust TLS clients; there is no insecure-TLS mode.
The inbox and ledger use checksummed ordinary files, not SQL or a database. Their roots must be pre-created private local directories outside the repository and action tree; shared and network filesystems are unsupported. Checksums detect damage, not a malicious local writer. A webhook inbox removes raw bytes after controller completion. The ledger retains running and saved work and keeps GitHub and Gitea-family exact-body completion markers permanently, because those signatures contain no trusted delivery time. A full store rejects new identities instead of evicting accepted work.
The separate artifact store retains exact report and external-assessment bytes only for its configured lifetime. It is bearer-authenticated and bounded by records, total bytes, bytes per evaluation, and expiry. It does not decide replay, freshness, debt, waivers, or acceptance. Corrupt, absent, expired, or full artifact state fails a new publication closed rather than publishing a locator the service cannot honor.
The resulting Check Run, policy-job result, or dedicated review is provider evidence, but the engine report remains an unchanged, self-asserted envelope. The controller neither signs it nor upgrades its sandbox claim. Provider-verified controls gives the exact setup, configuration, limits, storage rules, freshness and retry limits, rotation, and report distinction. No provider update is atomic with the local ledger; each provider page states its reconciliation limit. The GitHub convenience Action still invokes the public scanner directly and does not gain this trust boundary.
Verified consumption
The convenience Action’s manifest check is a coherence check, not a boundary: the manifest and the binaries arrive in the same tag tree, so whoever could rewrite one could rewrite both. The boundary a workflow can hold without operating a provider lane is the release attestation, whose trust root is sigstore and GitHub’s build records rather than anything this repository ships. Every release binary is attested at build, the release workflow verifies its own uploads before finishing, and with immutable releases the tag and its assets are locked at the platform after publication.
To run only what the attestation vouches for:
- env:
GH_TOKEN: ${{ github.token }}
run: |
gh release download v<reviewed-version> --repo HardMax71/amiss --pattern amiss-linux-x86_64
gh attestation verify amiss-linux-x86_64 --repo HardMax71/amiss \
--signer-workflow HardMax71/amiss/.github/workflows/release.yml
chmod +x amiss-linux-x86_64
./amiss-linux-x86_64 check --repo . --object-format sha1 \
--base "$(git rev-parse HEAD^)" --candidate "$(git rev-parse HEAD)" \
--profile enforce --format json > amiss-report.json
The verification proves the binary came from this repository’s release workflow on GitHub’s runners, byte for byte. It does not authenticate what the workflow around it does: a required check whose inputs and selection an opposing author cannot influence is still the provider lanes’ job. There is no launcher in the action tree; a verifier the artifact itself supplies could never vouch for the artifact, which is why this lane is a recipe over platform primitives rather than a file in the release.
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, declare exact source-to-document
projections, and raise the disposition of
explicit-target-missing, explicit-target-type-mismatch, and invalid-reference. Raise
only: repository policy combines with the built-in profile by maximum, so it can promote an
observe warning to fail and can never downgrade or suppress it. An unknown
field makes the whole file invalid and the run incomplete, which is what keeps the policy
from growing into a plugin system one field at a time.
The complete grammar in one example, with projection_assertions optional for compatibility
with policies written before projections existed and the file valid only whole:
{
"schema": "amiss/scanner-policy",
"document_includes": [
{ "path": "build/docs", "kind": "tree" },
{ "path": "docs", "kind": "tree", "suffix": ".txt", "adapter": "rst" },
{ "path": "notes/ARCHITECTURE", "kind": "document", "adapter": "markdown" }
],
"projection_assertions": [
{
"document": "docs/api.md",
"name": "request-shape",
"projection": "code-text-v1",
"sink": "previous-code",
"source": {
"kind": "blob-lines",
"path": "examples/request.json",
"first_line": 1,
"last_line": 12
}
}
],
"protected_inventory": ["docs/install.md"],
"finding_dispositions": [
{ "finding_kind": "explicit-target-missing", "disposition": "fail" }
]
}
The first tree include readmits a subtree the built-in skip list would drop, which is
Discovery’s monorepo lever. The second admits only .txt descendants of
docs and reads them as reStructuredText. The document include reads one extensionless file
under the markdown grammar. The protected path makes its removal a finding, and the disposition
row promotes one kind to fail. The
scanner-policy schema
closes the grammar, and each array keeps the sort order the schema states. The strictness
also sets the upgrade order: an engine that predates a policy field refuses the whole file
and leaves the run incomplete, so a repository grows its policy only after every engine
reading it has learned the field.
A projection assertion is owned by the policy, under the stable identity (document, name).
The code-text-v1 sources select either an inclusive one-based line interval from a tracked regular
or executable blob, or bytes between distinct exact start_marker and end_marker lines. Each
printable-ASCII marker line is at most 256 bytes, occurs exactly once, and is itself excluded. Amiss
does not parse a source language or its comment syntax. Duplicate, missing, reversed, same-line,
or non-UTF-8 regions are typed drift, while edits outside the selected region are irrelevant.
The source’s code-text-v1 projection is compared with the semantic Markdown or MDX code block
immediately before [amiss:<name>]: <amiss:projection>. It converts CRLF and bare CR to LF and
removes exactly one final line ending; it does not normalize indentation or any other byte. The
document must be in the scanner’s discovered set. Missing, duplicate, or non-adjacent sinks, and
absent, non-blob, LFS, or otherwise invalid sources, produce one projection-drift finding. A
marker with no matching policy row remains an unsupported reserved capability and makes the run
incomplete. Removing the marker while the policy row survives therefore cannot disable the
relation, while removing the policy row is policy-weakened even when the marker is removed too.
A record-value source applies code-text-v1 to one key in a named record-set@1 semantic
envelope. Each such envelope carries exactly one record-set object, including empty complete
sets, and its records are strictly ordered and unique by key. Keys are nonempty, control-free
UTF-8 of at most 4,096 bytes; display values have the same character law and a 65,536-byte cap.
The producer’s strings remain inert data: Amiss runs no formatter or template. A row that is
present can attest its value even when the envelope says the set is partial. A missing key means
source-record-absent only for a complete set; in a partial set it is
source-record-unproven, and a missing named set is source-record-set-absent. Evidence derived
from an earlier scanner report is not admitted as a projection source.
For a complete envelope, a record-set source applies sorted-rows-v1 to every display value in
UTF-8 byte order, or applies decimal-count-v1 to the exact number of records. Duplicate display
values remain distinct records because keys, not values, own identity. A partial set produces
source-record-set-incomplete for both projections before any equality, count, extra-row, or
absence conclusion is attempted. Row-difference previews remain byte-sorted and bounded.
The sorted-rows-v1 projection pairs the same sink with a tree-paths source: one existing tree
root, an optional exact suffix, and a positive maximum relative depth. It filters the complete
ordered snapshot map without another Git walk or object read, excludes directory entries, and
projects all other tracked paths relative to the root. A qualifying non-UTF-8 path or path with a
control character is typed drift; an invalid Git path already makes the candidate incomplete. Row
mismatches carry exact counts, a pure-ordering flag, and at most 32 rows and 32 KiB of byte-sorted
preview on each side, with exact omitted counts.
decimal-count-v1 applies to that same complete tree-paths source and emits only the canonical
unsigned ASCII member count. It includes qualifying non-UTF-8 and control-containing path names
because none are rendered. A sign, leading zero, grouping separator, label, or whitespace makes
the visible value noncanonical rather than a second spelling of the same count.
A document include names one exact path. A tree include names that path and descendants
separated by /; specs therefore covers specs/api.md but not specs-old/api.md. Matching
is bytewise, including for paths JSON cannot represent as text. A tree may carry one suffix:
2–64 UTF-8 bytes beginning with ., with no slash, backslash, or NUL. It selects only non-tree
entries at or below that root whose raw path ends in those exact bytes. There are no globs,
wildcards, regexes, excludes, normalization, or case folding, and built-in classifications still
win. The stable selector identity remains (path, kind), so changing or removing the suffix—or
replacing it with a broader tree—reports policy weakening instead of disguising the old selector
as a new one.
amiss policy-include prints a validated canonical row for the suffixed-tree form
without touching the policy file. Its optional staged-index preview applies this same matching
implementation and reports exact path identities; it does not invent excludes or merge the row
into existing controls.
Document includes, projection assertions, inventory members, and disposition rows share each
snapshot’s published repository-policy entry ceiling, so
the base/candidate classification union can contain twice that many distinct roots. Tree roots
and suffix roots are indexed separately; lookup probes path ancestors and suffix components,
never every policy row. The
policy tests
pin the semantic boundaries, and the release
eligibility test
checks the maximum union without scanning every policy row for every discovered path. The
amiss-scan controls benchmark tracks tree matching, suffix matching, and policy-set comparison
as the entry count grows.
External controls come from outside the repository, because anything stored inside it could
be rewritten by the very pull request under review. The contract defines five nullable controls: an
organization floor (tightens ceilings and dispositions across many repositories), an
adoption debt snapshot (a recorded list of known failures being worked off, mintable
from a real evaluation by amiss adopt), a waiver
bundle (time-limited permission to pass despite a named failure), trusted time, and an
execution constraint. The sealed request also carries a bounded set of
semantic-evidence envelopes, each paired with an independently planned
context digest, candidate-bound, and interpreted only by a compiled consumer. An ordinary
amiss check may instead bind one caller-selected candidate-free template after resolving its
exact candidate; that convenience input remains self-asserted and is not an external control.
Every control identity, and the release manifest’s, uses one open repository grammar: a
caller-canonical host, a slash-joined owner when the forge supports nested groups, and a
repository name. That admits enterprise and self-hosted instances without making them
impersonate a public host. In the evaluation request, candidate_ref is the candidate or
source branch used to recognize same-repository links; target_ref is the protected branch to
which the organization floor, trusted time, debt snapshot, and waiver bundle bind. They are
equal for an ordinary branch update but may differ for a pull or merge request.
default_branch_ref remains URL-resolution context and does not stand in for the protected
target. The
organization-floor,
debt-snapshot, and
waiver-bundle schemas, the
control parsers, and their
open-forge contract tests pin that grammar and
the exact repository/target-ref binding. The execution constraint additionally pins the action
tree, release manifest, platform, declared required-status name, and bootstrap in its
dedicated parser.
A status name is data, not proof of which provider integration published it; source-bound
enforcement remains an adapter responsibility.
Trusted time binds more than a timestamp. Its current parser requires the repository and protected target ref, a provider namespace, an opaque bounded provider run ID and positive attempt, and the candidate-identity digest. That candidate identity includes both candidate and target refs, the selected URL dialect, the repository, and the snapshots, so changing any of those cannot replay a statement for the same Git trees. The controls request must repeat the same provider/run tuple, and the verification gate compares it byte-for-byte before using the time.
These are binding rules, not authentication. The controller must authenticate provider input before constructing requests for the exact run. Its provider-neutral sequence and durable retry contract are documented in Controller delivery. The concrete provider lanes load organization policy and their execution constraint outside the checked repository, authenticate a signed webhook or policy-job token, refresh provider-owned change and merge-rule state, acquire the exact trees, derive trusted time, and run the sealed bootstrap. Their separate pages describe the Check Run, policy-job result, or dedicated review that carries provider evidence.
The request’s forge value remains only the URL dialect used by link resolution and is separate
from the controller’s provider namespace and instance identity. Debt must reproduce its adoption
tree, and a waiver item for another candidate tree is simply not selected. The commit and
staged-index paths share one
trusted-time, debt, and waiver pipeline.
Debt and waiver require verified trusted time and a complete Git candidate. An item
carries its accepted fact, and that fact is the sole source of the finding kind and the
key-input preimage; finding_key is recomputed from the nested key. The fact can name
only explicit-target-missing or explicit-target-type-mismatch. Selection needs an
exact current finding key with a candidate fact; a resolved projection or an absent key
is not an exception target. An exact forge commit is part of the normalized target intent, so an
exception for the same path at another immutable commit does not match. Matching also requires the
exact fact digest. When
everything lines up, active unchanged debt records tolerance at warn, and an applied
waiver changes only fail to warn. Invalid, expired, worsened, or overlapping items
suppress nothing, and an overlap makes evaluation incomplete. Both controls travel only
in the sealed request: amiss adopt mints a debt snapshot from the public grammar, but
no public flag supplies one back, so consumption belongs to the provider lanes.
The wrapper tests
pin binding, trusted-time, expiry, fact-drift, wrong-tree selection, resolved-target, and
overlap behavior. The published complete-findings, debt-items, and waiver-items
ceilings bound the accepted sets, and the amiss-scan pipeline benchmark
tracks matching as findings and exception items grow.
One asymmetry remains in the current control contract: the report can carry a finding on a document whose name is raw bytes, but waiver and debt items spell paths as text. Such a finding is reportable yet cannot be waived or adopted.
The machine-facing evaluation and controls requests are closed by the evaluation-request schema and controls-request schema, with matching strict parser tests. Their unversioned names are intentional: before 1.0 the shipped schema, parser, examples, and report form one rolling contract and move together.
In the public command and GitHub composite Action, all five external controls are absent and no
protected target ref is authenticated. The Action supplies no semantic evidence; a direct public
check may carry one self-asserted template. The report records
status: "none" separately for
organization floor, debt snapshot, waiver bundle, execution constraint, and trusted time; its
sandbox assurance is self-asserted. There is no aggregate provider_verified field. The
exact projection is built in the
report writer.
The sealed bootstrap path can carry all five controls and the bounded semantic-evidence set to the
engine. Its report acceptance
binds the requested profile; exact organization-floor, debt-snapshot, and waiver-bundle
presence, digest, and trust source; the execution-constraint digest, trust source, and
recomputed descriptor semantics; the trusted-time digest, provider run, instant, and recomputed
statement semantics; every semantic envelope’s payload and producer/input identities after its
planned context matches; and the
candidate identity and honest sandbox projection. The public
CLI shell still supplies
each nullable external control as None; its optional template is bound only inside the candidate
evaluation and leaves sandbox assurance self-asserted. A report control row with
status: "verified" means the engine verified
the supplied value’s digest and identity relationships. It does not prove that a provider
authenticated or supplied the value: neither the report nor its enum authenticates its own
source. The honest reading of a local or convenience-Action report remains: these findings, under
this repository policy, with no outside authority consulted. In a provider lane, origin is
evidenced separately by the App-owned Check Run, protected policy job, or dedicated review and
the matching merge rule; copied report bytes do not become an attestation.
The control-plane finding family closes the loop from the other side. When a candidate
weakens its own policy file or drops required coverage, the comparison raises
policy-weakened or coverage-reduced. With a verified organization floor,
control-plane-changed reports a protected control path unless both base and candidate are
present supported blobs with identical path, mode, and raw-content evidence. Loosening the
rules is reported under the rules being loosened.
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:
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.
The controller-owned plan binds external_policy into its digest. advisory is the default;
off and the opt-in block-confirmed-refutations pilot are defined by
The external assessment.
The same plan may name controller-local Intersphinx inventories:
"intersphinx_inventories": [
{
"identity": "python",
"base_url": "https://docs.python.org/3/",
"file": "/var/cache/amiss/python.objects.inv"
}
]
The list is optional and holds at most 64 unique identities. Each base is an absolute HTTP(S)
directory URL (a missing final / is normalized); each file is an absolute, regular, non-symlink
Sphinx v2 zlib inventory. The complete set may occupy at most 16 MiB compressed and 16 MiB decoded,
charged while each file is read rather than after the set is resident. The service parses the files
at startup with the pinned sphinx_inv grammar, retains only std:label rows whose destinations
satisfy the engine’s URI grammar and remain beneath their configured base, and binds the identities,
bases, exact source digests, and resulting complete observation set into the controller plan. A
malformed, partial, oversized, duplicate, or unresolvable inventory rejects configuration rather
than weakening the check.
The service does not download inventory files. Fetch or refresh them in operator-owned deployment automation and point the plan at the resulting local file; a CI or host cache is safe because its exact bytes are re-read, parsed, and digest-bound before use. The repository being checked cannot name or replace this file, and cached download state is never an authority or a repository artifact. See Trusted semantic evidence for the sealed engine boundary.
GitHub, Gitea, and Forgejo arrive as signed webhooks. A bounded receiver authenticates the exact
body and stores it before returning 202; a worker authenticates the stored bytes again. GitLab
uses a short-lived OIDC token from the policy job and waits synchronously for the result, because
the job’s own success is the protected evidence.
One tree, small crates
Each lane is a pair of small crates under
controller/: an adapter that speaks
one provider’s API and a service binary that deploys it. Provider differences end at those
crates. The shared controller stays provider-neutral, the engine gains no provider enum, and the
scanner report does not change shape because a forge was added.
Those crates are also a dependency boundary. HTTP clients, provider APIs, credentials, TLS, and
service storage live behind them, and no engine crate depends on any of them, so a cargo add amiss closure contains none of it. deny-engine.toml enforces that by dropping the provider
crates from the graph and banning the network and async stack in what remains. Auditing the
scanner never means auditing a webhook stack.
The lanes are deliberately unpublished: source-built services, not hosted Amiss products, release binaries, or registry crates. One commit of this repository pins everything a lane trusts at once: the engine, the wire contracts, the bootstrap whose digest the execution constraint binds, and the service source. Built at that commit, there is no second repository or registry whose version has to agree with the first. The contracts are pre-1.0 and still move together, so a version seam between engine and service would sit exactly where skew is most dangerous. It also keeps these pages honest: the lane documentation lives beside the lane code, and the repository’s own scan checks the references between them on every change.
Building the provider workspace requires the pinned Rust toolchain and a working C/C++ compiler for its AWS-LC cryptography backend.
Offline configuration check
Before starting a lane, run its service binary with --check and the same absolute config path
used at startup. The check uses the service’s strict loader, so it reads and validates the config,
the named credentials and trust files, the bound plan, the execution constraint, the bootstrap,
the limits, and the path layout.
It then exits before entering the service runtime, binding the listener, opening mutable inbox,
ledger, or artifact state, running the bootstrap, or contacting the provider. Success prints the
service name followed by configuration valid; failure prints the same configuration error that
startup would report.
Every service also answers --version on its own, with no config path, printing its name and
version and exiting 0. Use it to confirm which build a host is running before reading anything
into a lane’s behavior.
This is a local preflight, not readiness or provider evidence. It cannot prove that the configured address is available, that state roots are writable and healthy, that credentials have the required provider permissions, or that the live merge rule matches the documented setup. Those checks still require startup and retained runs against the provider.
Service operation
Every provider service uses the same three private operator GET endpoints:
| 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 fourteen label-free counters:
| Counter | Counts |
|---|---|
amiss_controller_provider_requests_total | Configured provider POST requests answered. |
amiss_controller_provider_acceptances_total | Provider requests answered successfully, including authenticated no-work deliveries. |
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. |
amiss_controller_external_refuted_total | External destinations a retained assessment refuted. |
amiss_controller_external_unproven_total | External destinations a retained assessment left unproven. |
amiss_controller_external_reachable_total | External destinations a retained assessment found reachable. |
amiss_controller_external_incomplete_total | External verifications that could not finish. |
The set cannot grow from a repository, request, provider identity, or result. It has no labels, and all values reset on restart. Counters that do not apply to a lane remain zero. The metrics endpoint remains scrapeable during drain until the listener closes; it does not make the listener safe to expose.
Each lane also serves the separately configured, bearer-authenticated artifact GET route.
Retained provider artifacts defines its URL, response, exact-byte,
expiry, retry, and storage contract. Artifact retrieval does not increment the fixed provider
request counters.
Runtime lifecycle events are one compact JSON object per stderr line. The schema is
amiss/controller-event/v1, and the only keys are schema, level, event, and component.
Normal transitions are ready, draining, and stopped, with level info and component
service. A required background component failure uses event failed, level error, and
component worker or maintenance. It appears before draining when the component initiates
shutdown and after draining when admitted work fails while finishing.
{"schema":"amiss/controller-event/v1","level":"info","event":"draining","component":"service"}
These events deliberately carry no request body, header, credential, repository, path, object ID, provider reply, or free-form error. This keeps lifecycle logging bounded and avoids echoing secret-bearing input.
On a termination signal, the service marks itself unready before it stops accepting new work. The HTTP server finishes requests already in flight. A webhook worker finishes its current delivery and leaves the remaining durable inbox backlog for the next process. The synchronous GitLab lane finishes admitted evaluations and any ledger maintenance already running. This includes blocking work whose provider connection closed after admission. A second termination signal aborts a stuck drain. Do not depend on a final metrics scrape after drain starts: the listener may close before the other components finish.
Bind this listener only to loopback or a private operator network. If a TLS proxy accepts provider
traffic, publish only the configured provider POST path and artifact GET prefix through it;
keep /healthz, /readyz, and /metrics private. None of the three operator endpoints is
authenticated; the artifact prefix has its own bearer authentication.
Shared trust boundary
Run a provider service on a host controlled independently of the checked repository. Keep its API credential, webhook secret or OIDC keys, bootstrap, execution constraint, optional controls, artifact bearer token, scratch directory, file-ledger root, and artifact root outside the repository and action trees. Webhook lanes also have a separate raw-inbox root. All roots must be pre-created private local directories; shared and network filesystems are unsupported.
Build the constraint from the exact local action and bootstrap bytes with Prepare the execution constraint. The generator checks every dependency lock and the selected platform’s runtime closure but does not authenticate either input; independent acquisition and protected placement remain operator responsibilities.
The listener is plain HTTP. Bind it to loopback or a private network and put an
operator-controlled TLS terminator in front. The proxy must preserve signed headers and the exact
body, and must cap connections plus total, header, body, idle, and slow-body time. /healthz
reports only process liveness; the full probe and drain contract is in
Service operation. A webhook service also takes one of its configured delivery
permits before reading a body and holds it through durable inbox admission. That bounds in-process
work. Both endpoint shapes stop an unfinished body after 30 seconds; neither limit replaces the
proxy’s public connection limits.
The hard ceilings are shared by every lane:
| Ceiling | Value |
|---|---|
| Request body | 8 MiB |
| Header count | 128 |
| Aggregate header bytes | 32 KiB |
| Ledger rows | 100,000 |
| Artifact records | 100,000 |
| Artifact total | 64 GiB |
| One artifact record | 1 GiB |
| Artifact retention | 365 days |
| 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. Exact artifacts and then the result are saved locally before an external provider update or GitLab’s synchronous success response. An ambiguous reply may therefore require reconciliation, and each provider page states what can and cannot be repeated safely. The file ledger and artifact store use bounded, checksummed ordinary files and atomic replacement; neither uses SQL or an embedded database.
Provider administrators, repository administrators who can change the protected merge rule, integration owners, policy-project owners, credential issuers, configured bypass actors, and anyone who controls the service host or its trust files remain inside the lane’s trust boundary. A lane proves only what those authorities jointly enforce.
What the report means
The engine report remains the same canonical evaluation envelope. It is not signed by the
provider or controller, its sandbox assurance remains self-asserted, and it has no
provider_verified field. A control with status: "verified" means that the engine checked the
control’s digest and identity bindings; it does not identify the caller.
Provider origin lives in the provider gate: the GitHub App-owned Check Run, the GitLab policy job, or the dedicated Gitea-family review, together with the matching protected-merge settings. Copied report bytes alone are not an attestation.
Cross-repository relations
Documentation and the implementation it describes may live in different repositories. A change to either one can introduce drift while the other repository remains byte-for-byte unchanged. The controller has the operator-owned registry and exact Git acquisition boundary needed to identify and materialize those relations without letting repository content choose another repository, credential, selector, limit, or publication target.
This is not yet a complete cross-repository checking lane. The shared service can load one bounded operator file into an immutable registry, and the controller can bind the complete set of opaque credential identities to caller-owned authorities. The service can also bind one opaque operator-declared coordination identity to the exact relation owned by an authenticated delivery, then freeze caller-resolved revisions only when the live registry still admits that relation and the trigger candidate reproduces the delivery. No provider service supplies that registry or constructs those authorities yet. The controller derives the canonical audit plan directly from the accepted trigger report and frozen transition. The service can project repository-backed sources from all four acquired snapshots, assess the result, retain the exact chain, and stage its destinations under the current scheduling fence. No provider service assembles those inputs into a live lane yet. The controller independently replays every supplied audit before storage and can reconcile a claimed GitHub or Gitea-family destination. The service can drain reopened claims through the frozen credential router and acknowledges only a provider-confirmed delivery. An authenticated live GitLab policy job can instead consume its exact synchronous destination.
One closed relation
One relation contains exactly two subjects and one of the existing projection kinds:
code-text-v1, sorted-rows-v1, or decimal-count-v1. Reusing the scanner’s projection vocabulary
keeps copied text, exact inventories, and counts under one comparison model instead of adding a
second selector language.
Each subject carries:
- a relation-local role;
- the exact provider family and instance, integration, and canonical repository identity;
- the operator-selected target branch and Git object format;
- an opaque credential reference, never credential bytes;
- one projection source checked through the scanner-policy source grammar; and
- independent acquisition-object, acquisition-byte, projection-record, and projection-byte limits.
The relation adds an identity, aggregate limits for the same four resources, and one or two status destinations. A destination names one of the two subject roles and a status name under the existing required-status grammar. The same credential reference may be configured for both subjects when a provider genuinely issues one appropriately scoped identity; the registry still retains the choice separately on each subject.
Construction is atomic. It rejects more than 1,024 relations, repeated relation identities, equal subject roles, equal repository identities, malformed or projection-incompatible sources, zero or overflowing limits, aggregate limits that cannot admit either subject or exceed both subject ceilings together, missing or repeated destinations, foreign destination roles, and malformed status names. Two relations also cannot own the same provider-instance, repository, and status-name key, even through different integrations or credentials. Subject, destination, and relation order is canonicalized before the private trigger index is exposed.
There is no mutation API. A successful construction owns immutable relation plans behind shared references; changing operator configuration requires building and installing another complete registry. A rejected construction exposes no partial index and cannot replace a live entry.
Credential routing is another atomic construction over that frozen registry. Every distinct opaque credential identity must have exactly one caller-owned authority under the provider instance and integration named by its subjects. One authority may cover several registered repositories under that scope. Missing authorities, extra or repeated rows, and reuse of one identity under another provider or integration are rejected. Lookup repeats the subject binding before returning the authority. Reopened status-target lookup repeats the same credential and provider-scope check, and neither the registry nor the router exposes a mutation API. The authority value can directly contain its concrete provider client and Git acquisition credential; the controller does not erase it behind another provider trait or interpret its secret bytes.
Authenticated triggering
Both subjects are trigger owners by construction. This removes a configuration branch that could silently check changes from only one side.
Lookup accepts an AuthenticatedDelivery, not a repository path, URL, webhook body, or policy
file. Its key is the authenticated provider instance, integration, repository identity, and object
format. Provider facts that disagree inside the delivery are an error. A coherent delivery outside
the registry is ordinary authenticated no-work. A matching delivery returns every affected
relation in stable relation-identity order together with the role that triggered it.
Coordination admission consumes the authenticated delivery and accepts only an operator-declared relation among that exact trigger set. Its result keeps the delivery, frozen relation, trigger role, and bounded opaque coordination identity together for execution. An unknown relation or an internally inconsistent delivery is an error. The service does not expose a coordination-policy enum or derive identity from a commit, timestamp, branch name, URL, or provider event spelling.
The configured target branch is not treated as an immutable revision. A provider resolver must refresh it and supply exact base/candidate commit and tree IDs for each role. The controller freezes all four revisions against the registered roles and object formats before acquisition. The trigger role’s candidate commit must also reproduce the delivery-authenticated provider run. The same trusted call supplies one bounded coordination identity naming the exact pair, release, or workflow occurrence. The relation configuration and its context digest define what that identity means; Amiss treats the spelling as opaque. Commit timestamps, nearby branch heads, URL versions, and repository prose are not pairing evidence.
Status preparation requires a fresh head fact for both subjects, not only the repository whose delivery triggered the audit. Each fact must reproduce the complete registered subject, including its provider scope, target, credential identity, selector, and limits. A changed subject binding or object format is invalid; a changed candidate commit or tree is superseded. Only then does the controller freeze the configured destination roles into a stable batch carrying the relation, coordination, trigger role, pending fence, exact provider scope and credential identity, candidate commit, and required status name. Selector, target-branch, and resource-limit fields have already served their finality proof and are not copied into the provider outbox. An unconfigured role never appears in that batch.
Pending and supersession law
The provider-neutral scheduler is a pure transition over an optional pending value and one newly frozen relation transition. The first exact value receives fence 1. Repeating the same operator plan, coordination identity, and four subject snapshots is a duplicate and preserves the original pending value, even when the other authenticated role triggered the repeat. A coordination identity cannot be rebound to different snapshots, and a relation identity cannot be rebound to different operator configuration.
A different coordination identity under the same relation advances the fence and becomes the new pending value. A worker holding the earlier fence is therefore superseded, while an audit it already retained remains immutable under its own artifact identity. Fence overflow fails closed. This model contains no clock and gives no lexical meaning to coordination identities.
The file-backed admission store applies that law under one cross-process lock. An atomically replaced committed head bounds a hash-chained append-only journal, so restart either observes the whole new binding or discards its uncommitted suffix. Every admitted coordination remains bound to its first exact work and fence: a delayed retry returns that historical fence but cannot become current again. New work appends one bounded record instead of rewriting all history, the immutable capacity applies only to new bindings, and a missing, shortened, reordered, rebound, or malformed committed record fails closed. The journal retains full configuration and work digests rather than credential references or complete operator configuration. Head-final status preparation is pure and does not authorize an external write: a later durable publisher must still stage the exact batch while proving its fence is current. Invoking either boundary from a live relation lane remains a separate stage.
The pure staging transition models that next boundary without choosing its disk format. It accepts only the current pending fence and fresh two-subject head facts, replays the complete relation audit against the pending transition, and binds its retained artifact reference and verdict to the exact destination batch. A first stage returns one direct record. An unfinished exact retry returns that same record, a completed exact retry returns no work, and substituting any target or audit field is a binding conflict. Completion changes only the terminal bit and is idempotent for the exact staged value. The file-backed relation journal applies that transition under the same cross-process lock as scheduling, so current-fence verification and the committed stage cannot race. The stage action first verifies the live retained artifact, then stores the relation identity, coordination, trigger role, fence, artifact identity, and one domain-separated binding over the complete typed target and audit record. It does not duplicate provider configuration or credential identities in the journal. Each configured external destination is retained in the staged action as a domain-separated digest of its stable provider-instance, repository, and status-name identity. After a provider accepts or reconciles the exact staged value, a separate hash-chained action acknowledges that destination. A foreign or repeated destination fails closed, and batch completion is refused until every staged destination has a durable acknowledgement. Completion is itself a separate hash-chained action; an exact retry is idempotent, while a missing or rebound record fails closed. Completed in-memory state keeps only the status binding digest.
Restart recovery now performs that reopening without persisting a second provider configuration. It reads the bounded retained relation audit by artifact identity, reconstructs the frozen transition from its exact snapshots and the immutable registry, replays the audit, and returns the staged batch only when the complete status binding is identical. A missing or rebound registry, expired artifact, or changed target remains a refusal. Reopening still grants no external delivery authority.
Delivery claims use 256 deterministic operating-system lock shards derived from those stable destination digests. For every destination, selection retains only its lowest unresolved fence; candidates sharing a shard are tried in stable fence, relation, and coordination order. The shard is acquired without holding the journal lock, then the journal is synchronized and selection is repeated before the registry and artifact are reopened. This lock order lets acknowledgements take the journal lock without deadlock. A newer coordination cannot reach the same destination first, while unrelated shards may proceed in parallel. A hash collision can reduce concurrency but cannot change selection or authority.
The returned claim directly carries the exact reopened status record and one target while a private file handle keeps its shard locked. Dropping it, including process failure, changes no durable state; the next attempt selects the same oldest unacknowledged destination. Acknowledgement consumes the claim and appends only after provider acceptance or provider-specific reconciliation. The final acknowledgement also appends completion under the journal lock. If failure lands between those two commits, the next claim pass completes the fully acknowledged batch before selecting more provider work.
The durable store still makes no provider call. A provider-neutral service loop obtains one claim, selects its authority from the frozen credential router, and passes the exact status and target to a caller-supplied publisher. Only publisher success consumes the claim and appends its acknowledgement. A routing or provider error drops the held shard unchanged, so restart selects the same durable destination again. Provider adapters must reconcile an ambiguous response for that exact value before returning success. The loop stops only when no destination is currently claimable; a live relation lane remains responsible for polling it again.
The GitHub installation client can independently refresh the final head of an operator-configured
GitHub subject. It accepts the typed subject directly, requires the configured provider and
installation, a canonical repository on that provider instance, and SHA-1, then resolves the
declared branch and validates both the returned commit and its tree. The result is a typed
RelationSubjectHead; it does not choose a credential, schedule work, or authorize publication.
Credential routing and the two-subject finality decision remain controller-lane responsibilities.
The same client accepts one exact status record and one destination from a durable delivery claim.
It rejects a completed or malformed batch, a target not present exactly once, another provider or
installation, a noncanonical repository, a non-SHA-1 candidate, or an inconsistent relation audit
before provider I/O. The check is attached to the subject candidate commit under the
operator-configured relation status name. aligned and resolved-drift conclude success;
introduced-drift, pre-existing-drift, and unproven conclude failure. Its credential-free
summary binds the relation, coordination, fence, roles, target, verdict, and report, plan, evidence,
and assessment digests. A domain-separated digest of that complete projection is the external ID.
Before creating anything, the client lists the App-owned checks for that exact commit and name. It reuses one exact external-ID and output match, creates only when none exists, and rejects duplicate or conflicting matches. A lost create response therefore leaves the claim unacknowledged; a retry normally reconciles the accepted run before the caller durably acknowledges the destination. The GitHub API and local journal still have no shared transaction, so a stale provider read can expose a duplicate later and make subsequent reconciliation fail closed.
The Gitea-family client independently refreshes an operator-configured subject through the
authenticated commit endpoint. It requires the configured provider and dedicated reviewer, a flat
canonical repository on that provider instance, and SHA-1 before resolving the declared branch.
Both the returned commit and tree must be exact SHA-1 names. The result is the same typed
RelationSubjectHead used by provider-neutral finality; full object acquisition remains a separate
bounded Git operation.
The client projects the same checked, credential-free value onto the exact candidate commit through the native commit-status API. It applies the same scope requirements before listing statuses. The list is bounded to 1,000 rows and ordered by the provider’s commit-status index. For the configured context, the client reuses an exact latest row, advances a different valid Amiss marker written by the same reviewer, and rejects a foreign, malformed, or same-marker conflict. A create succeeds only when the provider echoes every field and the exact reviewer.
The short versioned description carries a domain-separated digest of the same complete projection; the status state carries success or failure. Gitea and Forgejo do not bind a required status context to its writer, so checking the response’s reviewer protects Amiss reconciliation but cannot make the provider merge rule identity-secure. Use this surface for an unchanged relation subject only with that limitation understood; publish the actual protected gate on an identity-bound destination when one is required.
GitLab has no asynchronous relation publisher. Its adapter accepts an already authenticated policy job and a relation subject only when both name the configured project, integration, SHA-1 format, and protected target branch. A fresh provider refresh must still prove that exact job, pipeline, merge-train car, candidate, runner, policy origin, project controls, and branch protection active. The resulting head fact is the ephemeral merge-train candidate, not the mutable protected-branch head.
The same live-job boundary can consume one staged relation destination. Its scope and candidate must match the authenticated delivery, and its status name must be the exact configured policy job name. The shared relation projection supplies only a pass or block decision; another final refresh must still find the job and train active before the caller lets the endpoint return success. No GitLab API write occurs. A stopped job cannot be resumed by a background publisher, and a lost success response still fails the provider job closed even if Amiss retained the completed local result.
Exact Git acquisition
The existing strict HTTPS protocol-v2 shallow fetch now accepts a positive object and pack-byte ceiling and returns its measured pack usage. Caller ceilings can only narrow Amiss’s global 2,000,000-object and 2 GiB pack ceilings. The streaming pack validator enforces the selected limits before indexing, so an oversized response is never accepted and counted afterward.
Relation acquisition sorts inputs by role, binds each canonical HTTPS repository URL and opaque credential identity back to the operator plan, and fetches both exact commits for each subject into its own root. The first subject’s measured usage is subtracted from the aggregate budget; the second receives the smaller of its own ceiling and what remains. Both roots are then reopened by the bounded repository reader, and every commit must name the independently resolved tree.
Any missing object, transport failure, cancellation, exhausted budget, wrong tree, or aliased root
makes the complete relation unproven; no partial relation result is returned. Roots must remain
physically distinct even when two independent repositories happen to produce identical Git object
IDs. SHA-256 subjects remain representable in the registry but are unproven through the current
SHA-1-only provider transport.
Report-bound audit plan
After the four snapshots are frozen, the controller derives a separate 64 KiB wire plan from that transition and the accepted trigger report. The canonical plan records the exact comparison that later evidence must reproduce. It contains:
- the accepted scanner report payload digest and the role whose authenticated change selected it;
- the relation identity and a context digest for the complete operator-owned configuration;
- the exact operator-supplied coordination identity;
- one shared projection kind; and
- exactly two role-sorted subjects, each with its canonical repository, selected target branch, compatible source selector, object format, and exact base/candidate commit and tree IDs.
The coordination identity records intent and the four object pairs identify its exact comparison; neither is derived from the other. It does not impose an order, deadline, or lifecycle by itself. The target branch remains explanatory selection context. Base and candidate may be identical for an unchanged subject, and the two subjects may use different object formats. Repository identities and roles must differ, and the trigger role must name one subject. Credentials, raw provider tokens, and transport budgets are deliberately not copied into the portable document.
The checked writer and strict reader share the scanner’s existing projection-source grammar. A
code-text-v1 plan therefore accepts blob lines, a named region, or one record value;
sorted-rows-v1 and decimal-count-v1 accept tree paths or one record set. The plan does not add a
second selector language or let either repository change the operator-owned selector.
The context digest is an integrity binding for operator configuration interpreted outside the wire; it is not authority by itself. Likewise, the report digest and trigger role are closed facts, but the wire reader does not have the report and cannot prove they agree with it. Controller admission performs that binding before retaining the sidecar. The plan intentionally contains no projected value, completeness claim, alignment verdict, blame assignment, or status policy. Malformed branches, selectors, identities, object IDs, subject ordering, unknown fields, and a changed payload refuse the whole document.
Projection evidence
A second closed 64 KiB document binds projection evidence to the exact plan payload digest. It has
the same two byte-sorted role rows, each with independent base and candidate slots. A slot is
either null or one complete projected value:
value_digestis the plain SHA-256 of the exact canonical projected bytes; andvalue_bytesis the nonnegative safe-integer length of those same bytes.
Null means that producer did not establish one complete value for that exact slot. It does not mean
an empty value, a missing source, or a mismatch, and it cannot participate in an equality claim.
There is no complete flag that can disagree with nullable digest fields and no partial projected
value whose absence claims would be ambiguous. All four slots may independently remain null; a
missing evidence document remains distinct from a present receipt that records four unproven
attempts.
The projection kind in the plan defines the canonical bytes. For code-text-v1, blob-line and
named-region selections normalize CR and CRLF to LF and remove one terminal LF; record values use
their exact UTF-8 value bytes. sorted-rows-v1 byte-sorts the complete selected rows and joins them
with one LF and no trailing LF. decimal-count-v1 uses the canonical ASCII decimal item count
without leading zeroes. The compact receipt does not copy potentially multi-megabyte values merely
to compare them twice.
After acquisition, the Git projector rebuilds the plan, binds every repository, selector, commit, and tree back to the frozen operator relation, and reopens the two physically independent roots. It visits roles in canonical order and each role’s base before its candidate. Each snapshot receives the smaller of the subject budget and aggregate budget that remains. Blob-line and named-region sources count one selected record and charge the larger of the source blob or canonical projected value. Tree-path sources count every selected path and charge the larger of their combined path bytes or canonical output. A crossed budget or untrusted Git object refuses the complete operation; a missing, unsupported, or incomplete repository source records a null slot. Record-value and record-set slots also remain null until a separately authenticated, snapshot-bound producer exists.
The evidence reader establishes shape and payload integrity only. Repeating a plan digest is not producer authority, and matching role spellings are not checked until the plan and evidence are assessed together. Unknown fields, malformed digests or roles, reordered or repeated rows, unsafe byte counts, and a changed payload refuse the whole receipt. The evidence contract carries no verdict and never identifies which subject should change.
Equality transition
A third closed 64 KiB document records the deterministic offline assessment. It binds the accepted
report, exact plan, optional evidence, and evaluator version and digest. Before comparing values,
the evaluator rebuilds both supplied envelopes, requires the evidence to name the exact plan, and
requires its two role rows to match the plan. Any absent evidence, foreign plan digest, mismatched
role, or null projection slot yields unproven with one corresponding reason.
For four complete slots, projected values are equal only when both their digest and byte length are equal. The two booleans map to exactly one transition:
| Base equal | Candidate equal | Verdict |
|---|---|---|
| yes | yes | aligned |
| yes | no | introduced-drift |
| no | no | pre-existing-drift |
| no | yes | resolved-drift |
These names describe equality over time, not correctness. Both roles participate symmetrically;
the assessment neither chooses an authority nor says which repository should change. A proved
transition carries a null reason, while unproven carries exactly one of evidence-absent,
evidence-unbound, role-mismatch, or projection-unproven. Inconsistent verdict/reason/evidence
combinations and a changed payload refuse the assessment instead of being normalized.
The assessment is replayable over the exact bound documents, but the portable evidence document does not authenticate its producer by itself. The acquisition projector establishes repository slots under the operator-owned relation and resource limits. Before storage, the controller reopens the accepted scanner report, binds its repository, target, and exact snapshots to the trigger role, rechecks the operator plan, and independently replays the evidence and assessment. The artifact store retains the report, plan, optional evidence, and assessment under one immutable, restart-safe identity; retries may reproduce the same bytes but cannot substitute any component.
The service execution boundary composes those existing checks as one Result flow. It first rejects
work whose fence is already stale, derives and parses the canonical plan, projects both independent
roots, assesses the typed evidence under the caller-supplied evaluator identity, retains the complete
audit, and asks the durable scheduler to stage its exact head-checked destinations. The early fence
check avoids known-wasted projection work; it is not publication authority. Status staging repeats
the fence and final-head checks under the scheduling lock, so supersession racing the projection can
still expose no destination. An exact unfinished retry reproduces the retained artifact and staged
record instead of rebuilding a parallel orchestration path.
Trust boundary
The registry lives only in the unpublished controller layer. The offline engine still has one declared repository root, no network or async dependency, no credential input, and no ability to follow a link into another repository. Projection sources in this registry are operator input; similarly shaped repository policy does not add a relation or gain access to its credential reference.
The remaining live-lane stages are deliberately separate:
- construct concrete provider authorities and install the frozen registry and router in a service;
- resolve and acquire snapshot-bound records from the admitted coordination in live provider lanes.
Until those stages exist, the service can execute and stage an exact bounded repository comparison when its caller supplies the current pending transition, accepted report, two acquired roots, final heads, stable evaluation identity, and evaluator identity. Given a frozen router and provider publisher, it can also resume and drain the retained destination batch after restart. No provider service yet assembles the complete relation lifecycle.
The implementation is in the provider-neutral relation registry, the bounded registry loader, the exact relation transport, the repository projector, the durable scheduler, the relation laws, and the durable scheduling laws exercise bidirectional selection, stable ordering, four-revision binding, independent roots, projection compatibility, joint budgets, exact destinations, restart recovery, delayed retries, capacity, corruption, and concurrent admission.
Prepare the execution constraint
Every provider lane loads one execution constraint from operator-owned storage. It pins the action repository, exact action commit and tree, release manifest, target platform, stable result name, and the exact bootstrap executable the service may run.
amiss-constraint builds that existing contract from a local action checkout and bootstrap. It
does not introduce another configuration format.
Build
Build the bootstrap and the companion tool from the same reviewed source commit as the provider service:
cargo build --release --locked -p amiss-bootstrap --bin amiss-bootstrap
cargo build --release --locked \
-p amiss-controller-constraint --bin amiss-constraint
Both land in target/release; no system-wide installation is needed. Windows binary names carry the normal .exe suffix.
Acquire the published action repository independently, as an ordinary non-bare checkout with a
real .git directory. Choose and record the full immutable action commit. The checkout must
already contain that object; the tool does not fetch, run git, read a ref, or consult HEAD,
remotes, and worktree files.
Then create a new constraint file:
target/release/amiss-constraint \
--action-repository /absolute/path/to/action-checkout \
--action-identity github.com/example/amiss \
--action-commit eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee \
--bootstrap /absolute/path/to/amiss-bootstrap \
--required-status-name "amiss / assure" \
--output /absolute/path/to/execution-constraint.json
A lone --version reports the tool’s own version instead of building anything, which is the way
to confirm the producer matches the release it is provisioning for.
Use native absolute paths on the deployment system. --action-identity is the separately
supplied logical host/owner/name; a nested GitLab owner such as
gitlab.example/platform/security/amiss is valid. The tool does not read a Git remote or compare
that identity with the manifest’s build source. For a provider-local mirror, use the exact commit
present on that provider even if recreated commits differ from upstream.
--action-commit is the action-tree commit consumers pin, not the source commit recorded inside
the release manifest. Current provider lanes require a full 40-character SHA-1 commit, and this
provider-facing command rejects every other object format.
The output path must not exist. The tool validates everything before publishing the canonical file and never replaces an existing file. On success it prints the constraint’s semantic digest, which is useful for the deployment record but is not a signature.
The bootstrap and output parent must resolve outside the action checkout. Create a private output directory first. Generate the file as the service account or have the deployment mechanism make the new file readable by that account without exposing it to the checked repository.
Supplied and derived values
Only the values that require an operator decision are supplied:
| Supplied 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, 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.
Retained provider artifacts
Provider summaries are intentionally short. A publication can carry more findings than GitHub’s Check Run or a Gitea-family review should display, and an external assessment is useful only with the exact plan and evidence that produced it. Each provider lane therefore retains those bytes in an operator-owned artifact store before it stages or publishes the result.
The store is evidence retention, not replay or acceptance authority. The
FileLedger still decides which delivery may run, retry, or complete. Repository
content cannot configure the artifact root, URL, token, lifetime, or capacity, and retained bytes
never suppress or approve a later finding.
Published binding
Every report-bearing provider publication binds all of the following:
- the canonical report digest;
- when semantic evidence was accepted, the canonical semantic-input audit digest;
- an HTTPS report locator;
- the authorization scheme,
bearer; - the exclusive expiry instant in Unix milliseconds; and
- when external verification completed, the canonical assessment digest.
GitHub Check Runs and Gitea-family reviews carry these as report, artifact,
artifact-auth, artifact-expires-unix-millis, and optional semantic-input,
semantic-input-artifact, and assessment lines. GitHub leaves the Check Run’s native details URL
unset because that browser link cannot supply the required bearer header; authorized clients use
the locator in the summary. A completed external assessment adds its direct assessment-artifact
locator and its refuted, unproven, and reachable counts; an incomplete one is named as incomplete
instead of inventing counts. GitLab returns the report locator as
Link: <...>; rel="amiss-report", the semantic-input sibling as rel="amiss-semantic-input", and
the assessment sibling as rel="amiss-assessment" when each exists. It returns authorization,
expiry, assessment state, and completed counts as X-Amiss-* headers. The component digests are
X-Amiss-Report-Digest, X-Amiss-Semantic-Input-Digest, and
X-Amiss-Assessment-Digest. The report URL always ends in /<artifact-id>/report; retained
semantic inputs use the sibling /semantic, and exact external inputs use /plan, /evidence,
and /assessment.
The controller writes the exact report, accepted semantic-input audit value, and optional external chain before its final provider refresh and publication stage. The artifact identity binds the evaluation ID, every component digest, and the external outcome. A retry first verifies the saved reference and every retained component, then republishes the already staged value. It never reruns the scanner, semantic producer, or external verifier. A changed head or gate after verification stages a superseded result with the retained chain, not the old pass or block. Rebinding one evaluation ID to different bytes is an error.
The same store can retain a validated publication audit as a separate immutable record. Its
reference carries the ordinary report artifact plus the exact plan, optional evidence, assessment,
and verdict digests. The record survives restart and reopens only when its metadata, every retained
byte, and the evaluation binding still agree. Publication audit components use the distinct
siblings /publication-plan, /publication-evidence, and /publication-assessment; /plan,
/evidence, and /assessment remain the external-link verification chain. This storage surface
does not acquire a deployment receipt or publish an audit outcome. A later audit lane must retain
the validated chain before staging any provider-visible result.
If retention, validation, or retrieval cannot be trusted, a new publication fails closed. A summary without a retained locator says extra findings are “not displayed”; it never claims that an inaccessible report exists. Expiry cannot change a provider verdict that already completed. After expiry, a duplicate delivery may still be recognized by the delivery ledger but no longer advertises an artifact.
Authenticated retrieval
The configured base_url must be a canonical HTTPS URL with a non-root static path and no
credentials, query, fragment, empty path segment, or trailing slash. Route segments use only
letters, digits, -, ., _, and ~. The TLS proxy must forward that path and preserve the
Authorization header without exposing the three private operator endpoints.
The service reads one 32-to-256-byte bearer token from a bounded regular file at startup and keeps only a keyed verifier in memory. Give the token only to authorized authors or operators. Retrieve the report exactly as published:
AMISS_ARTIFACT_TOKEN="$(</etc/amiss/artifact.token)"
curl --fail --silent --show-error \
--header "Authorization: Bearer ${AMISS_ARTIFACT_TOKEN}" \
'https://amiss.example/amiss/artifacts/<artifact-id>/report' \
--output report.json
When the publication advertises a semantic-input component, retrieve its exact source templates
and candidate-bound envelopes with the same token from
https://amiss.example/amiss/artifacts/<artifact-id>/semantic. Recompute the advertised digest
before using any component as audit evidence.
An authorized publication-audit client retrieves its three components from the publication
siblings above. An unproven audit intentionally has no /publication-evidence component; its
assessment binds a null evidence digest instead. The immutable artifact identity binds the audit
digest set and verdict; callers must keep that complete reference with any staged audit outcome.
Token files are exact bytes and cannot contain whitespace, including a trailing newline. Changing the token requires a service restart but not a new artifact root. If consumers must keep access to old locators, retain the old token until their published expiry instants.
An authorized GET returns the unchanged JSON bytes with Content-Type: application/json,
Cache-Control: private, no-store, and X-Content-Type-Options: nosniff. Missing or wrong
authorization returns 401; an unknown component or expired artifact returns 404; a query or
oversized header set returns 400; unavailable storage or request capacity returns 503.
Artifact requests share the configured endpoint header and concurrency bounds but do not change
the fixed provider-request counters. Their own semaphore additionally derives a response-memory
cap from artifact_record_bytes: at most 128 MiB of configured component bounds can run together,
except that one explicitly allowed component may itself be as large as the fixed 256 MiB machine
JSON ceiling. This is a concurrency bound, not a startup allocation.
Storage and limits
paths.artifacts names a fourth pre-created private local directory, separate from scratch,
ledger, and the webhook inbox. One process owns the root. Shared and network filesystems,
symlinks, unknown entries, malformed metadata, missing payloads, and digest mismatches fail closed.
Metadata is checksummed, payloads are digest-checked, creation is metadata-last, and deletion is
metadata-first, so an interrupted operation is either recoverable debris or a complete record.
The optional execution-limit fields are:
| Field | Default | Hard ceiling |
|---|---|---|
artifact_retention_seconds | 604,800 (7 days) | 31,536,000 (365 days) |
artifact_records | 1,000 | 100,000 |
artifact_bytes | 1 GiB | 64 GiB |
artifact_record_bytes | 64 MiB | 1 GiB |
All values must be positive, and one record’s limit cannot exceed the total-byte limit. Record and byte accounting includes metadata plus every retained component. Full capacity rejects new evidence; it never evicts a live artifact. At the exact expiry instant the artifact becomes inaccessible. Startup and store operations remove expired records, and a persisted clock high-water mark prevents already removed bytes from returning after clock rollback.
The base URL, retention, and capacity limits are recorded with a root. Changing any of them requires a new empty artifact root. Size the record limit for the largest report, semantic-input audit value, external chain, or publication-audit chain a lane is allowed to retain, and size total bytes plus record count for the expected publication rate over the retention period.
GitHub provider lane
The unpublished
amiss-controller-github-service
crate serves one GitHub repository, one App installation, and one protected target branch. It
supports GitHub.com and compatible GitHub Enterprise Server (GHES) releases, and it is built from
source rather than distributed as a hosted service, container, or release binary.
This lane is separate from the published GitHub convenience Action. The Action is the simple way to run Amiss inside a repository workflow. The service is the stronger path: its webhook secret, App key, policy files, bootstrap, and state live outside the repository being checked.
Flow
The receiver accepts only the configured POST path with no query string. It bounds headers
and body before admission and verifies GitHub’s HMAC over the exact body. A supported pull-request
event is checked against the configured repository, target branch, and plan, then saved raw before
the receiver returns 202 Accepted; the worker authenticates the saved bytes again before use.
Other authenticated events return the same success without creating an inbox row.
The supported pull-request actions are opened, reopened, and synchronize. An edited
event is accepted only when its signed changes.base.ref.from field records a base-branch
change. Other edits and non-pull-request events do not create work. Classification uses the signed
body, not the unsigned X-GitHub-Event header. Invalid JSON and malformed supported pull-request
payloads still fail authentication.
Using an App installation token, the adapter refreshes the exact repository, pull request, base and candidate commits and trees, default branch, GitHub test merge, and effective rules for the configured protected branch. The test merge must be ready and mergeable, with the exact base and candidate parents and evaluated tree. The adapter acquires the repository and pinned action revision into private directories. The provider-neutral controller then runs the sealed bootstrap, retains the external chain when enabled, refreshes GitHub again, saves the exact result, and publishes it on that authoritative test-merge commit. A changed head or gate, closed pull request, removed authorization, timeout, missing output, or tampered runtime cannot turn the new evaluation into a pass.
The raw inbox is not the replay authority. It removes a row after the controller finishes.
The separate FileLedger keeps the final delivery state and makes a repeated
publication use the same evaluation and result.
GitHub App
Create a GitHub App owned by the account that controls the repository. Give it only these repository permissions:
| 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. |
| Actions | Read | Read configured workflow runs and their semantic-evidence artifacts. |
| 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 and
workflow_run
webhook events, set a strong webhook secret, and grant the installation access to the configured
repository. The pinned action repository must be on the same provider instance. If it is another
private repository, that installation must be able to read it too. GHES operators must therefore
mirror and pin the action on their GHES instance; the lane will not cross from GHES to github.com
for runtime code. The service does not need repository Administration permission: it reads the
effective rule and refuses when the expected rule is absent.
GitHub automatically subscribes Apps with Checks write access to check_run and check_suite
events. The lane
authenticates those deliveries and returns 202 without queueing work. It also returns no work
for workflow runs outside the configured producer and for configured runs that did not succeed.
Create an active branch ruleset for the configured target branch. Enable strict required checks,
so GitHub requires the pull request to be up to date with that branch. Add a required status check
whose name exactly matches required_status_name in the execution constraint, and select this
GitHub App as the expected source. GitHub documents why selecting an App matters:
a required check from another person or integration must not satisfy that rule.
The service reads the
effective rules for the branch, including
applicable organization rules. A missing rule, a non-strict rule, an “any source” rule, or a
conflicting rule revokes authorization for that run.
Classic branch protection is not supported by this lane. Configure a ruleset even if the repository also has a classic protection rule.
Build and run
First prepare the execution constraint for the exact action commit, bootstrap, and required App-owned check name used by this lane.
Build the service. Its network dependencies belong to the provider crates and never enter the engine’s own closure:
cargo build --release --locked \
-p amiss-controller-github-service --bin amiss-controller-github
Pre-create the private scratch, inbox, ledger, and artifact directories, then run the shared offline configuration check:
target/release/amiss-controller-github --check /etc/amiss/github.json
Start the service with the same absolute config path:
target/release/amiss-controller-github /etc/amiss/github.json
The listener is plain HTTP. Bind it to loopback or a private network and put a TLS terminator in front of it. The proxy must preserve the exact body and required GitHub headers; it must not decode, decompress, or rewrite the signed body. The service takes a configured delivery permit before reading a body and holds it through durable inbox admission, but it does not own the public connection budget. The proxy must still cap concurrent connections and apply total, header, body, idle, and slow-body deadlines. Forward the configured artifact prefix only to consumers who can present its bearer token. Keep the probes and metrics private, and use the shared service operation contract for readiness, redacted lifecycle events, counters, and graceful drain.
The delivery endpoint returns:
| Status | Meaning |
|---|---|
202 | The authenticated request is durable, was already saved, or requires no work. |
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 four writable roots must already exist as separate real directories; none may contain another. The bootstrap must be a real file whose digest matches the loaded execution constraint.
{
"listen": "127.0.0.1:8080",
"webhook_path": "/webhooks/github",
"github": {
"instance": "github.com",
"api_base": "https://api.github.com",
"app_id": 12345,
"installation_id": 67890,
"private_key_file": "/etc/amiss/github-app.pem",
"webhook_keys": [
{
"id": "current",
"secret_file": "/etc/amiss/webhook.secret",
"active_from_unix_millis": 1784764800000,
"active_until_unix_millis": null
}
]
},
"repository": {
"id": 112233,
"owner": "example",
"name": "project",
"target_branch": "main"
},
"plan": {
"profile": "enforce",
"external_policy": "advisory",
"execution_constraint_file": "/etc/amiss/execution-constraint.json",
"organization_floor_file": "/etc/amiss/organization-floor.json",
"debt_snapshot_file": null,
"waiver_bundle_file": null,
"workflow_artifacts": [
{
"workflow_identity": "docs-evidence.yml",
"event": "pull_request",
"artifact_name": "amiss-semantic-evidence",
"payload_file": "amiss/semantic-template.json",
"archive_byte_limit": 33554432,
"file_byte_limit": 16777216,
"semantic": {
"acquisition_identity": "github-docs-evidence",
"producer_kind": "site-build",
"producer_identity": "docs-site",
"producer_version": "0.5.1",
"context_digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111"
}
}
]
},
"paths": {
"bootstrap": "/opt/amiss/amiss-bootstrap",
"scratch": "/var/lib/amiss/scratch",
"inbox": "/var/lib/amiss/inbox",
"ledger": "/var/lib/amiss/ledger",
"artifacts": "/var/lib/amiss/artifacts"
},
"artifacts": {
"base_url": "https://amiss.example/amiss/artifacts",
"bearer_token_file": "/etc/amiss/artifact.token"
}
}
Repository owner and name are lowercase. target_branch is one branch name such as main, not a
full refs/heads/... value. It binds admission, the plan route, effective-rules lookup, and the
protected target of every run.
The same installation client also exposes a provider-only finality read for a registered relation subject. The caller still selects the client through operator-owned credential routing. The client then requires that subject’s provider instance, installation, canonical repository, branch, and SHA-1 format to match before it reads the current commit and validates the returned tree. This read does not acquire repository contents, stage a relation result, or publish a check run.
workflow_artifacts defaults to an empty list. Each row becomes part of the plan digest and uses
the configured provider and repository; neither is accepted again inside the row. The workflow
identity is a workflow file name or numeric GitHub workflow ID. All rows use the same workflow and
event, so one successful completion proves that every planned artifact can exist before evaluation
starts. While this list is nonempty, the earlier pull-request webhook is authenticated no-work
rather than an acquisition that races the build. The completion’s signed payload must name exactly
one pull request and reproduce the configured base repository, installation, target branch,
candidate SHA and ref. Exact redelivery is deduplicated; unrelated checks and workflows never enter
the inbox. The case-sensitive artifact name, sole ZIP member, acquisition identity, producer
contract, context digest, and both byte limits must reproduce the producer exactly. Archive and
payload limits are positive and capped at 32 MiB and 16 MiB respectively. Acquisition requires
exactly one completed successful run for the
authenticated candidate SHA and exactly one unexpired artifact whose linked run and repository
metadata, declared size, and SHA-256 digest all agree. The signed download URL receives no App
credential and may redirect no further.
instance is github.com for GitHub.com. For GHES, use its lowercase host as instance and its
REST root, normally https://github.example/api/v3, as api_base. The server must support the
App, Actions workflow-run and artifact, rules-for-branch, pull-request, commit, and Check Run APIs
used by this lane under the pinned GitHub API version. Its HTTPS certificate must chain to a CA
trusted by the service’s Rust TLS clients; there is no insecure-TLS switch. The API URL must use
HTTPS and the provider host; credentials, ports, query strings, and fragments are rejected.
This lane accepts exact SHA-1 object IDs and Git protocol v2. A GHES deployment must support both. The execution constraint’s action repository must name that same GHES instance.
The App private key, webhook secrets, execution constraint, and optional controls are loaded from bounded regular files when the service starts. Secret-file bytes are exact: an accidental trailing newline changes the webhook secret. A webhook key is active from its inclusive start through its exclusive end. Overlapping windows allow rotation; removing an old key revokes it.
The optional limits object has separate execution and queue sections:
{
"limits": {
"execution": {
"api_request_millis": 20000,
"git_request_seconds": 120,
"bootstrap_seconds": 120,
"artifact_retention_seconds": 604800,
"artifact_records": 1000
},
"queue": {
"max_concurrent_deliveries": 16,
"inbox_records": 64,
"retry_max_millis": 60000
}
}
}
Each omitted field uses its default:
| 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 |
execution | artifact_retention_seconds, artifact_records | 604,800, 1,000 |
execution | artifact_bytes, artifact_record_bytes | 1 GiB, 64 MiB |
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, retained artifacts, and bootstrap. Queue fields govern only webhook admission, the durable raw inbox, and its worker.
Configuration cannot raise the shared hard ceilings: 8 MiB per request body, 128 headers, 32 KiB of aggregate header bytes, 100,000 ledger rows, 1,024 inbox rows, 128 MiB for the whole inbox, 16 MiB for one inbox row, 100,000 artifact records, 64 GiB of artifacts, 1 GiB per artifact record, 365 days of retention, and 64 concurrent admissions. HTTP phase and operation timeouts cannot exceed 30 seconds, Git acquisition cannot exceed 120 seconds, and the queue poll cannot exceed five seconds. Smaller values remain available for a tighter deployment. The full artifact contract and retrieval command are in Retained provider artifacts.
Provider API responses are capped at 8 MiB. Effective-rule and Check Run lists stop at ten pages of 100 rows, or 1,000 rows total. Oversized responses, inconsistent counts, malformed pages, and pagination that does not finish inside that bound fail closed.
Git acquisition has a second, fixed fail-closed budget:
| Git 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
All state stores use checksummed ordinary files with bounded rows or bytes and atomic replacement. There is no SQL server, embedded database, or schema migration service. Use private local filesystems; network and shared filesystems are unsupported. Anyone who can read or alter the App key, webhook secret, artifact token, control files, bootstrap, scratch root, inbox, ledger, artifact root, or TLS proxy is inside this lane’s trust boundary. GitHub, repository and organization administrators, App owners and key issuers, and configured ruleset bypass actors are also trusted. None of these actors is made atomic with the local file records.
Only one live process may own an inbox. It has fixed row, byte, and per-row caps. A full inbox
returns 503 instead of dropping an accepted request. A crash leaves a claimed row available
for a later retry; successful controller completion removes the raw body.
The ledger has its own fixed record cap. GitHub signs the body but no delivery timestamp, so a
completed exact-body replay marker is permanent. Cleanup must not invent an age for it. Size
ledger_records for the expected lifetime before creating the root: when it is full, new
identities fail closed while saved work can finish. Changing its lease, cap, or replay window
requires a new empty root. Changing the repository, installation, target branch, or plan also
changes the service route; drain the old inbox before starting that route on another empty inbox.
The hard 100,000-row ceiling gives one webhook-secret trust period a finite delivery lifetime. Before it fills, stop the old route, replace the GitHub webhook secret, remove the old secret from the service key ring, and start a new route with empty inbox and ledger roots. Do not overlap the old secret with the empty ledger: a captured old delivery would authenticate without its old replay marker. Keep the stopped ledger as an audit record, but it no longer needs to serve replay checks once the old secret is permanently revoked. This cutover can miss an event, so leave the required check in place and trigger a fresh pull-request event after the new route is live.
Controller-plan, external-control, execution-constraint, bootstrap, repository, or target-branch
changes need a required-check context rotation. Choose a new required_status_name and add it to
the strict ruleset, still bound to this App, while the old required context remains. Start the new
route on an empty inbox and prove that it can publish the new check. The overlap fails closed
because both checks are required. Then remove the old required context and stop the old route.
Preserve the existing ledger: its permanent rows are still the replay record for deliveries
accepted under the old route. Do not reuse the old status name for a different plan merely
because the local files changed.
Published evidence
The Check Run is attached to GitHub’s authoritative test-merge commit, not merely the pull
request’s head commit. The evaluation ID becomes its external_id. Before reading or creating a
run, the adapter fetches the pull request again. If its head, base, refs, or test-merge commit no
longer matches the staged publication, the stale delivery completes without writing a Check Run.
This keeps an older out-of-order delivery from cancelling or replacing evidence on the newer gate.
Otherwise the adapter reads checks for that gate commit, required name, and App. It ignores
historical rows with another external ID, reuses one exact current match, and fails closed on
duplicate or conflicting current rows.
Crash recovery across artifact-feedback upgrades admits older projections only when every stable
field matches. The summary may lack either the additive semantic-input pair alone or that pair
together with the older assessment-artifact and external-assessment lines. The adapter reuses
that already-created Check Run without rewriting it. Normal graceful drain avoids this state;
every other mismatch still fails closed.
A claimed cross-repository relation destination uses the same exact reconciliation machinery but
targets its configured subject candidate commit and relation status name, not a pull request or
test-merge commit. aligned and resolved-drift publish success; introduced or pre-existing
drift and an unproven comparison publish failure. The credential-free summary and its
domain-separated external ID bind the relation, coordination, fence, target, verdict, and all four
audit digests. The caller may append the durable destination acknowledgement only after GitHub
accepts that exact run or the adapter finds one exact prior match.
| Controller 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, report digest, authenticated artifact locator, and
exclusive expiry. The native details URL remains unset because a browser click cannot carry the
artifact bearer header; authorized clients retrieve the summary’s locator. Accepted semantic
evidence adds the audit artifact’s digest and sibling locator. Completed external verification
adds the assessment locator and its three verdict counts, while incomplete verification says so
without counts. An unavailable result also carries one stable failure
label such as timeout or tampered-runtime. Below those bindings the summary lists the report’s
own grouped feedback:
the fix, check, and existing counts, then up to ten items naming a target and an affected-place
count, every repository-derived value rendered under the human-atom law so hostile path bytes
never become markdown or workflow syntax. The digest stays the evidence; the listed lines are a
courtesy projection of the same report, and the retained locator provides every row.
Together with a strict active ruleset bound to this App, that Check Run is provider evidence for
the configured branch.
GitHub’s create-Check-Run call has no transaction with the local ledger. If GitHub accepts a create but its reply is lost, the exact result remains staged and the worker retries. A later lookup normally finds and reuses the run, but an ambiguous response followed by a stale lookup can create a duplicate; once both are visible the adapter fails closed. This is retry reconciliation, not an atomic exactly-once claim.
Required checks are commit-scoped. If two pull-request numbers resolve to the identical GitHub test-merge commit, an App-owned green check with the same required name can satisfy both. The service also reacts to signed events; it does not continuously poll every old gate. A ruleset, authorization, policy, or credential change does not proactively revoke an already green commit without a new accepted event. Use the status-name rotation above for policy and trust changes, and treat repository and organization administrators, App owners, key issuers, and ruleset bypass actors as part of the trust boundary.
The engine report itself remains unchanged. It is canonical evidence of what the engine
evaluated, but it is not signed by GitHub or the controller. status: "verified" on a control
means the engine checked that control’s digest and identity bindings; it does not identify the
caller. Sandbox assurance also remains self-asserted. There is no provider_verified report
field. Consumers that need provider origin must inspect the App-owned Check Run and its ruleset,
not treat copied report bytes as an attestation.
GitLab provider lane
The unpublished
amiss-controller-gitlab-service
crate serves one GitLab project, one pipeline execution policy, and one protected target branch.
It supports GitLab 19.3 or newer with Ultimate. The minimum comes from enforced merge trains,
which are generally available from 19.3.
GitLab 19.2’s feature-flagged preview is not supported. An instance below that floor is refused
where it first shows: its project response carries none of merge_pipelines_enabled,
merge_trains_enabled, merge_trains_skip_train_allowed, or merge_train_enforcement, so the
adapter cannot read the project at all.
This lane does not use a project webhook or write a commit status. An independently owned pipeline execution policy injects one job into the merge train. That job presents a short-lived GitLab OIDC token to the service and waits. Only an exact Amiss pass returns HTTP success, so the job’s own result is the provider evidence required by the train.
The provider adapter also has the narrow boundary needed by a future cross-repository relation lane. It can resolve this job’s live merge-train candidate as a relation head and bind one exact staged relation result back to the same project, candidate, and configured job name. This remains synchronous: it does not create a GitLab status, and no service loads or runs relation configuration yet.
Flow
The request body contains only the merge request’s project-local number, which GitLab calls its IID:
{"merge_request_iid": 42}
The bearer token, not that body, supplies the project, pipeline, job, runner, policy origin, exact
train commit, issue time, and replay ID. The service requires an RS256 token under one configured
key ID, issuer, and audience. It then binds the job_project_id, canonical
job_project_path, pipeline_id, job_id, runner_id, runner_environment, sha,
pipeline_source, job_source, and policy job_config claims. The pipeline source must be
merge_request_event, and the job source must be pipeline_execution_policy.
The first provider refresh reads the exact job, pipeline, merge-train car, merge request, project, target branch, protected-branch rules, and Git objects. The service requires the job, pipeline, and train to be running and to name the same train commit. It runs the sealed bootstrap only after that state and both acquired trees agree. External verification, when enabled, is retained before a second refresh performs the same checks and accepts the staged result.
GitLab project
Use a SHA-1 project on a root-mounted HTTPS GitLab instance. On gitlab.com a fresh account must pass identity verification before any hosted-runner job runs; until then every pipeline fails jobless with the verification banner as its only error. Configure the checked project as follows:
- enable merged-results pipelines and merge trains;
- set merge-train enforcement to Enforce for all users, including Owners and administrators;
- require pipelines to succeed and do not count skipped pipelines as successful;
- disable the option that lets a merge request skip the train;
- use the
mergemerge method, set squash tonever, and do not enable squash on the merge request; and - protect the target branch with direct push and force push disabled.
The service reads every protected-branch rule whose exact or wildcard name matches the target.
At least one must match, and every match must report allow_force_push: false and one nonempty
push-access entry with access level 0 and no user, group, deploy key, or member-role exception.
A broader matching rule that restores a push path revokes the run.
The merge method is part of the tree proof. The train result must have exactly two parents: the target or previous train car first, and this merge request’s source commit second. Fast-forward, semi-linear, rebased, or squashed train shapes are not supported.
These requirements follow GitLab’s merge-train enforcement and protected-branch contracts. The adapter checks the live API response rather than trusting the service configuration to describe it.
Pipeline execution policy
Keep the security policy project and included CI configuration outside the checked project and under the operator who owns this lane. Use an enabled pipeline execution policy whose complete shape is:
pipeline_execution_policy:
- name: Amiss documentation gate
description: Runs Amiss on every merge-train result
enabled: true
pipeline_config_strategy: inject_policy
content:
include:
- project: security/amiss-policy-job
file: policy-ci.yml
ref: eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee
policy_scope:
projects:
including:
- id: 101
suffix: never
skip_ci:
allowed: false
no_pipeline:
allowed: false
variables_override:
allowed: false
exceptions: []
dotenv: respect_policy
The content.include.ref must be an immutable 40-character commit, not a branch or tag. This
closes a separate movement path: GitLab’s OIDC job_config.url and job_config.sha identify the
security-policy YAML, while the included CI file contains the job itself. Pinning the include
makes the policy commit bind both. suffix: never makes a duplicate project job fail instead of
being renamed. Skip and no-pipeline allowances remain false, and no variable or dotenv exception
may alter the job. Scope the policy to the exact checked project.
The included job requests one OIDC ID token with the exact audience configured in the service. Its only security decision is the service response. A minimal shape is:
amiss:policy:
stage: .pipeline-policy-post
image: registry.example/security/http-client@sha256:<reviewed-image-digest>
allow_failure: false
id_tokens:
AMISS_ID_TOKEN:
aud: https://amiss.example/gitlab/policy/evaluate
variables:
GIT_STRATEGY: none
script:
- 'test "$(curl --fail-with-body --silent --show-error --output /dev/null --write-out %{http_code} --header "Authorization: Bearer ${AMISS_ID_TOKEN}" --header "Content-Type: application/json" --data "{\"merge_request_iid\":${CI_MERGE_REQUEST_IID}}" https://amiss.example/gitlab/policy/evaluate)" = 204'
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_EVENT_TYPE == "merge_train"'
- when: never
Keep the script on one physical line. Policy injection carries the included file’s newlines into the merged configuration literally, so a wrapped scalar runs each fragment as its own command and the first live train taught this the hard way.
Replace the image and endpoint placeholders with operator-owned, immutable values. Do not add a
fallback command, an alternate success path, or a project-controlled variable that can change the
endpoint, audience, headers, body, job name, or rules. The service also pins the exact
job_config.url and SHA reported in the OIDC token, so a job copied into the checked repository
does not authenticate as the policy job. The event-type test matters: detached and merged-result
merge-request pipelines also report merge_request_event, but only a merge-train pipeline reports
CI_MERGE_REQUEST_EVENT_TYPE=merge_train. The explicit status test rejects every response other
than 204, including a successful-looking redirect, proxy page, or alternate 2xx response.
The job needs no GitLab API or repository credential. Its OIDC token is short-lived and specific to that job. The controller keeps its API and Git credentials outside the pipeline.
Credentials and OIDC keys
Use separate controller-owned credentials:
- an API token with read access to the configured project, jobs, pipelines, merge trains, merge requests, branches, commits, and protected-branch settings; and
- an HTTPS Git credential with read access to both the checked project and the pinned action repository.
Store each token as exact bytes in a private regular file. A trailing newline is part of the token
and makes the configuration invalid. The Git username is explicit; for a personal, project, or
group access token it is commonly oauth2, but use the value required by the chosen GitLab
credential.
The service does not fetch OIDC keys at runtime. Export the instance’s current RSA signing keys
from its JWKS, review them, and pin each public key in a private file with its exact kid and a
local anchor name. One through sixteen unique keys are accepted. The issuer must be the configured
GitLab HTTPS instance, and the API root must be exactly /api/v4.
For key rotation, add the new key beside the old key and restart the service before GitLab starts using it. Keep the old key until every job token it signed has expired and any in-flight request has finished, then remove it and restart again. Removing a key revokes requests signed only by that key.
The policy also names trusted runners. Enable GitLab-hosted runners only when they are part of the deployment’s trust boundary. Otherwise list the exact positive self-hosted runner IDs. A generic “self-hosted” claim without a listed ID is rejected.
Build and run
First prepare the execution constraint for the exact action commit, bootstrap, and stable controller result name used by this lane.
Build the service from source:
cargo build --release --locked \
-p amiss-controller-gitlab-service --bin amiss-controller-gitlab
Pre-create the private scratch, ledger, and artifact directories, then run the shared offline configuration check:
target/release/amiss-controller-gitlab --check /etc/amiss/gitlab.json
Start the service with the same absolute config path:
target/release/amiss-controller-gitlab /etc/amiss/gitlab.json
Before binding the listener, the service opens and validates both state roots, migrates and cleans the ledger, and removes expired artifacts. Each admitted policy job then gets a fresh fenced owner session from the prepared ledger root. A normal request does not repeat full-ledger maintenance, so separate evaluations can remain concurrent. After startup, the service runs ledger cleanup once per minute outside request handling; artifact operations enforce expiry themselves. Ledger scans use a blocking worker, skip missed intervals, and never overlap. If a scan or worker fails, the service stops instead of continuing with unvalidated ledger state. Stop every v0.9 controller process before the first upgraded open; the ledger metadata upgrade is one-way.
The service listens on plain HTTP. Bind it to loopback or a private network and put an
operator-controlled TLS terminator in front. The proxy must preserve the Authorization header
and exact body and must cap connections plus total, header, body, idle, and slow-body time. Set
the policy job timeout above the service’s API, Git, and bootstrap deadlines. Forward the
configured artifact prefix with its separate bearer token. Keep the probes and metrics private,
and use the shared
service operation contract for readiness, redacted
lifecycle events, counters, and graceful drain.
max_concurrent_evaluations is an in-process cap from 1 through 64. The service takes a permit
after validating headers and before reading the body, then holds it through the complete blocking
evaluation. Capacity exhaustion returns 503; the proxy’s connection cap is still required.
Configuration
Configuration is strict JSON. Unknown and duplicate fields are errors. All file and directory paths are absolute. The scratch, ledger, and artifact roots must already exist as separate real directories outside the repository and action trees, and the bootstrap must match the loaded execution constraint.
{
"listen": "127.0.0.1:8080",
"evaluation_path": "/gitlab/policy/evaluate",
"max_concurrent_evaluations": 4,
"gitlab": {
"instance": "gitlab.example",
"api_base": "https://gitlab.example/api/v4",
"api_token_file": "/etc/amiss/gitlab-api.token",
"git": {
"username": "oauth2",
"token_file": "/etc/amiss/gitlab-git.token"
},
"oidc": {
"issuer": "https://gitlab.example",
"audience": "https://amiss.example/gitlab/policy/evaluate",
"trust_set": "gitlab-oidc",
"keys": [
{
"kid": "current",
"anchor": "gitlab-key/current",
"public_key_file": "/etc/amiss/gitlab-oidc-current.pem"
}
]
}
},
"policy": {
"integration": "pipeline-execution-policy/1",
"project_id": 101,
"project_path": "acme/widget",
"target_branch": "main",
"job_name": "amiss:policy",
"config_url": "https://gitlab.example/security/policies/-/blob/ffffffffffffffffffffffffffffffffffffffff/.gitlab/security-policies/policy.yml",
"config_commit": "ffffffffffffffffffffffffffffffffffffffff",
"gitlab_hosted_runners": true,
"self_hosted_runner_ids": []
},
"plan": {
"profile": "enforce",
"external_policy": "advisory",
"execution_constraint_file": "/etc/amiss/execution-constraint.json",
"organization_floor_file": "/etc/amiss/organization-floor.json",
"debt_snapshot_file": null,
"waiver_bundle_file": null
},
"paths": {
"bootstrap": "/opt/amiss/amiss-bootstrap",
"scratch": "/var/lib/amiss/scratch",
"ledger": "/var/lib/amiss/ledger",
"artifacts": "/var/lib/amiss/artifacts"
},
"artifacts": {
"base_url": "https://amiss.example/amiss/artifacts",
"bearer_token_file": "/etc/amiss/artifact.token"
}
}
project_path is the lowercase path with its complete nested group prefix. target_branch is one
branch name, not a full ref. job_name is the exact live GitLab job name. config_url and
config_commit must reproduce the policy job’s job_config OIDC claims exactly. config_url is
GitLab’s blob URL for the security-policy YAML, not the included CI file; copy the exact claim
rather than assembling the URL by hand.
integration is a controller identity for this policy binding; change it when the policy trust
boundary changes.
Only a root-mounted HTTPS instance without an explicit port is supported. The API root may end in
/api/v4 or /api/v4/; credentials, alternate paths, query strings, fragments, redirects, and
insecure TLS are rejected. The checked project and action repository must both use that instance
and SHA-1.
The optional limits object overrides execution defaults:
| 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 |
artifact_retention_seconds, artifact_records | 604,800, 1,000 |
artifact_bytes, artifact_record_bytes | 1 GiB, 64 MiB |
queue_age_seconds remains part of the authenticated replay window; it does not create a raw
request queue for this synchronous lane.
The GitLab endpoint further clamps the effective body to 1 KiB and the header count to 32; a
smaller configured value still wins. Future skew cannot exceed 300 seconds, the API request
deadline must fit inside the ledger lease, and the bootstrap limit cannot exceed 120 seconds.
Provider responses share one 4 MiB budget per refresh. Git acquisition uses the fixed pack,
object, inflated-byte, resolved-byte, delta-depth, and one-thread indexing limits listed in the
GitHub lane.
Protected-branch lookup stops at ten pages of 100 rows; an unfinished or oversized list fails
closed.
The underlying shared ceilings are 8 MiB per request body, 128 headers, 32 KiB of aggregate header bytes, 100,000 ledger rows, 100,000 artifact records, 64 GiB of artifacts, 1 GiB per artifact record, 365 days of retention, and 64 concurrent evaluations; GitLab’s smaller endpoint clamps win where they overlap. HTTP phase and operation timeouts cannot exceed 30 seconds, and Git acquisition cannot exceed 120 seconds. See Retained provider artifacts for the exact retrieval and lifecycle contract.
HTTP result and replay
The endpoint returns:
| 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. |
A completed result with a still-live artifact also returns Link: <...>; rel="amiss-report",
X-Amiss-Artifact-Auth: bearer, and X-Amiss-Artifact-Expires-Unix-Millis: <instant>. A completed
result with retained semantic inputs adds an amiss-semantic-input Link target and
X-Amiss-Semantic-Input-Digest. A completed external assessment adds an amiss-assessment Link
target, its digest, and refuted, unproven, and reachable count headers; an incomplete one carries
only the incomplete state. These headers appear with X-Amiss-Report-Digest on both 204 and
completed 412 responses; only the status decides the policy job. Retrieve each advertised Link
target with the separately configured artifact bearer token and recompute its SHA-256 digest.
The service checks bounds, OIDC, and the configured plan before creating an owner session, touching
a delivery row, or starting API, Git, or runner work. The policy job must treat only 204 as
success. Do not turn any other response into a warning or retry it inside the same script. A
GitLab job retry receives a new job and token; the adapter will bind that new run independently
if the merge-train car is still active.
There is no raw webhook inbox. The synchronous request remains open while the controller uses its
ordinary-file ledger, acquires both repositories, runs the bootstrap, and performs the final
refresh. The OIDC jti, runner ID, and authenticated issue time form a bounded replay identity.
Completed rows remain through their inclusive replay end and can then be removed by ledger
cleanup. A clock rollback cannot reopen an expired row.
New-row admission reads only the capacity frame and exact row path. A full root returns 503;
request handling never turns that rejection into a full-root scan. Startup and periodic
maintenance remove bounded rows after their replay lifetime ends, freeing their slots without a
service restart.
The final “publication” step makes no GitLab API write. It retains the exact report and external
chain, refreshes the same job and gate one last time, stages that result and locator in the ledger,
and lets the endpoint status decide the already running policy job. The local record and HTTP
response are not one transaction. If the service completed but the 204 reply was lost,
replaying the same token and request does not invent a second success; it fails closed as a
duplicate and returns the same still-live artifact locator.
For a policy, plan, bootstrap, control, project, target, job, runner, or action change, update the
independently owned policy configuration, pin its new config_commit, choose a new integration
identity, and restart the service with the matching files. Existing train jobs from the old
policy commit are rejected. Preserve the ledger until its bounded replay rows have expired.
What the job proves
A successful job proves that the independently owned policy supplied the configured job, GitLab signed its exact job and train claims, the live project still enforced the required merge path, and the service accepted an Amiss pass for that exact train-result tree. The API token, Git credential, OIDC keys, policy project, runner set, protected-branch administrators, GitLab instance, service host, TLS boundary, bootstrap, controls, scratch root, and ledger root remain inside the trust boundary. The artifact token and root are trust inputs too.
The engine report remains unchanged and self-asserted. It has no provider signature or
provider_verified field. GitLab origin lives in the protected policy job and enforced merge
train, not in copied report bytes.
Gitea and Forgejo provider lane
The unpublished
amiss-controller-gitea-service
crate serves one repository, one dedicated reviewer account, and one protected target branch. The
same data-shaped adapter supports Gitea 1.27 or newer and Forgejo 16 or newer. It does not infer a
forge from HTTP headers: the operator sets the provider namespace, and live API capabilities
decide which supported protection shape is present.
This lane uses a review because Gitea-family status contexts are not bound to the identity that wrote them. A protected branch can require an approval from one named account, so the service owns that reviewer account and writes the final review itself.
Flow
The receiver accepts only the configured POST path with no query string. It bounds headers and
body, takes a configured delivery permit before reading the body and holds it through durable
admission, requires one agreed family signature value, verifies lowercase
HMAC-SHA256 over the untouched body, binds the configured repository and target branch, and saves
the raw request before returning 202 Accepted. The worker verifies the saved bytes again.
The source accepts opened, reopened, and synchronized pull-request actions. An edited
event is accepted only when its signed change record says that the base ref changed. The unsigned
delivery UUID is not trusted; replay identity is a domain-separated digest of the exact signed
body.
Using the dedicated reviewer’s token, the adapter refreshes the token identity, repository, pull
request, target branch, effective protection rule, exact commits, and existing reviews. Tree names
come from the Git objects, not from the API: both families answer /git/commits/{sha} with the
commit name in the commit’s tree field, and every tree route echoes whatever name it was given,
so no route states the tree of a commit. The adapter fetches the two commits over HTTPS, reads
each tree from the proven object, and refuses a REST body whose parents disagree with it.
It requires the event head to remain current, the pull request to be open, and the head to be
based on the current target. A pull request Gitea reports as unmergeable is an unsettled provider
state rather than a verdict, because Gitea reports mergeable: false for the second or two it
spends recomputing a merge after a push and offers no separate “computing” signal. The lane
retries such a refresh instead of publishing on it, so a pull request that stays unmergeable
receives no review at all and cannot merge. It then acquires exact SHA-1 objects, runs the sealed
bootstrap, retains the external chain when enabled, refreshes everything again, saves the result,
and posts or reuses one exact review.
| Controller 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, report digest, authenticated artifact
locator, and exclusive expiry. Accepted semantic evidence adds the audit artifact’s digest and
sibling locator. Completed external verification adds the assessment locator and its three verdict
counts; incomplete verification says so without counts. Below those bindings it lists the report’s
grouped feedback the way the GitHub summary does: counts, then up to ten items with atom-rendered
targets; the locator provides every row. The required_status_name from the execution constraint
is a readable review label and retry binding; the provider gate itself is the dedicated reviewer
identity.
Crash recovery across artifact-feedback upgrades admits the same older additive projections as the GitHub lane, but only for an existing review whose stable fields still match. A newly created review must echo the complete current body exactly.
Dedicated reviewer
Create a separate restricted account for this lane. Give it administrator access to the checked repository and read access to the pinned action repository when that is separate. The account must be able to submit official pull-request reviews. Do not use a maintainer’s personal account or reuse one reviewer for another plan on the same protected branch.
Write access is not enough. On Gitea 1.27.0 and Forgejo 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 and configured relation commit statuses.
On current Forgejo this means read:user and write:repository; the account’s repository access
should supply the remaining boundary. Follow the equivalent current Gitea token scopes and
Forgejo’s token-scope contract. Store the
token as exact bytes in a private regular file. A trailing newline is part of the token and makes
the configuration invalid.
The reviewer token, account recovery path, and any session able to act as that account are trust anchors. A human who can approve as the reviewer can satisfy the gate without Amiss.
Protected branch
An exact protection rule for the configured target branch is easiest to audit and is recommended. A wildcard rule is supported: the service reads the branch’s effective rule name, fetches that rule, and requires the two names to match. Keep overlapping wildcard priorities under the same operator control. The service refuses the run unless all of these common facts are true:
- direct push is disabled;
- push allowlists are disabled and empty;
- no deploy key may push;
- unprotected file patterns are empty;
- exactly one approval is required;
- approvals are restricted to exactly the dedicated reviewer, with no team;
- rejected reviews block merging;
- stale approvals are dismissed and not ignored;
- an outdated pull request cannot merge;
- administrators must follow the rule.
The empty unprotected-file rule matters. Gitea documents that such patterns permit direct pushes to selected files even when ordinary push is disabled. A missing, false, unknown, or contradictory protection capability fails closed.
The setup names follow the providers’ Gitea protected-branch and Forgejo branch-protection pages. The live API response, not the UI label, is what the adapter accepts.
The two supported API shapes close their remaining paths differently:
- Gitea must report force push and bypass disabled, every related allowlist empty, no deploy key
able to force-push,
block_admin_merge_override: true, and repositoryallow_manual_merge: false. - Forgejo must report
apply_to_admins: true. Forgejo 16’s official repository response omitsallow_manual_mergeand the Gitea-only force-push and bypass fields, so the adapter requires those fields to be absent rather than inventing values for them.
The adapter accepts exactly one complete shape and rejects mixed or contradictory fields. This is a capability check on live provider data, not a branch on a provider-name enum.
Webhook
Create a repository webhook for pull-request events:
- method
POST; - content type
application/json; - a strong random secret;
- the configured service URL; and
- the native Gitea or Forgejo webhook type.
Gitea sends X-Gitea-Signature. Forgejo sends X-Forgejo-Signature and X-Gitea-Signature
together, carrying one value under both spellings. Both are lowercase hexadecimal HMAC-SHA256 over
the raw body. The service reads whichever of the two names the request states and accepts them
when they agree, since agreeing spellings are one claim. Spellings that disagree, and one name
stated twice, are ambiguous and are rejected. The TLS proxy must not decode, decompress, trim, or
rewrite the body.
See the native Gitea and Forgejo webhook contracts for provider-side delivery setup.
Neither signature carries a trusted creation time. A completed exact-body replay marker is therefore permanent. Removing it would reopen a signed request that remains valid.
Build and run
First prepare the execution constraint for the exact action commit, bootstrap, and review label used by this lane.
Build the service from source:
cargo build --release --locked \
-p amiss-controller-gitea-service --bin amiss-controller-gitea
Pre-create the private scratch, inbox, ledger, and artifact directories, then run the shared offline configuration check:
target/release/amiss-controller-gitea --check /etc/amiss/gitea.json
Start the service with the same absolute config path:
target/release/amiss-controller-gitea /etc/amiss/gitea.json
Bind the plain HTTP listener to loopback or a private network and put the bounded TLS edge described in Provider-verified controls in front. Forward the configured artifact prefix only with its bearer authentication. Keep the probes and metrics private, and use the shared service operation contract for readiness, redacted lifecycle events, counters, and graceful drain.
The delivery endpoint returns:
| 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.
{
"listen": "127.0.0.1:8080",
"webhook_path": "/webhooks/gitea",
"provider": {
"namespace": "gitea",
"instance": "forge.example",
"api_base": "https://forge.example/api/v1",
"reviewer": {
"id": 77,
"login": "amiss-controller",
"token_file": "/etc/amiss/reviewer.token"
},
"webhook_keys": [
{
"id": "current",
"secret_file": "/etc/amiss/webhook.secret",
"active_from_unix_millis": 1784764800000,
"active_until_unix_millis": null
}
]
},
"repository": {
"id": 101,
"owner": "example",
"name": "project",
"target_branch": "main"
},
"plan": {
"profile": "enforce",
"external_policy": "advisory",
"execution_constraint_file": "/etc/amiss/execution-constraint.json",
"organization_floor_file": "/etc/amiss/organization-floor.json",
"debt_snapshot_file": null,
"waiver_bundle_file": null
},
"paths": {
"bootstrap": "/opt/amiss/amiss-bootstrap",
"scratch": "/var/lib/amiss/scratch",
"inbox": "/var/lib/amiss/inbox",
"ledger": "/var/lib/amiss/ledger",
"artifacts": "/var/lib/amiss/artifacts"
},
"artifacts": {
"base_url": "https://amiss.example/amiss/artifacts",
"bearer_token_file": "/etc/amiss/artifact.token"
}
}
Use namespace forgejo for Forgejo. Provider instance, repository owner, repository name, and
reviewer login are lowercase canonical values. target_branch is one branch name such as main,
not a full ref. Only root-mounted HTTPS instances are supported; ports, user information, query
strings, fragments, alternate API roots, and insecure TLS are rejected.
The optional limits object has two strict sections:
{
"limits": {
"execution": {
"api_request_millis": 20000,
"git_request_seconds": 120,
"bootstrap_seconds": 120
},
"queue": {
"max_concurrent_deliveries": 16,
"inbox_records": 64,
"retry_max_millis": 60000
}
}
}
Omitted values use the defaults listed in the
GitHub lane’s limit table. execution covers ingress,
ledger, provider HTTP, Git, and bootstrap bounds. queue covers webhook concurrency, the raw
inbox, retry, and polling. Execution limits also configure the artifact retention, record count,
total bytes, and per-record bytes listed in the
GitHub lane’s table. max_concurrent_deliveries must be
between 1 and 64.
The shared 8 MiB body, 128-header, 32 KiB aggregate-header, 100,000-ledger-row,
1,024-inbox-row, 128 MiB inbox, 16 MiB inbox-row, 100,000-artifact-record, 64 GiB artifact,
1 GiB artifact-record, 365-day retention, and 64-concurrent-delivery ceilings apply here too.
See Retained provider artifacts for retrieval and lifecycle rules.
The action repository in the execution constraint must use the same provider host and SHA-1 object format. The reviewer’s token must be able to read it. The service requires Git protocol v2 and uses the fixed pack limits described in the GitHub lane.
State, replay, and rotation
The inbox, ledger, and artifact store use bounded checksummed files, not SQL or an embedded database. One process owns each inbox and artifact root. The worker removes raw bytes only after controller completion; the ledger retains the exact result and permanent body-replay marker, and the artifact store retains the exact report, optional semantic-input audit value, and optional external chain until the published expiry.
The hard 100,000-row ceiling gives one webhook-secret trust period a finite delivery lifetime. Before it fills, stop the old route, replace the provider webhook secret, remove the old secret from the service key ring, and start a new route with empty inbox and ledger roots. Never accept the old secret against that empty ledger: a captured old delivery would authenticate without its old replay marker. Keep the stopped ledger as an audit record, but it no longer needs to serve replay checks once the old secret is permanently revoked. The cutover can miss an event, so leave the reviewer requirement in place and trigger a fresh pull-request event after the new route is live.
Review creation is not atomic with the ledger. Before creating a review, the adapter refreshes the pull request and existing reviews. It reuses one exact current review and rejects a conflicting review carrying the same evaluation marker. If the provider accepted a create but its reply was lost, a later lookup normally finds the exact review; an ambiguous stale lookup can still create a duplicate, after which conflicting state fails closed.
Cross-repository relation publication has no pull request to review. The same client therefore
uses GET and POST /repos/{owner}/{repo}/statuses/{sha} on the exact frozen commit. It reconciles
the provider’s latest row for the configured context, accepts only the dedicated reviewer’s
versioned Amiss marker, and requires an exact create response before the caller may acknowledge its
durable destination. The marker is a digest of the complete credential-free relation projection;
no credential or bearer artifact URL is exposed.
Before a relation is frozen for evaluation, the client can also resolve a configured subject branch
through GET /repos/{owner}/{repo}/git/commits/{branch}. It first proves that the token still names
the configured dedicated reviewer, then requires exact SHA-1 commit and tree names in the response.
This only supplies a current-head fact; the service must still route the declared credential and the
existing bounded Git transport must acquire and verify the frozen objects.
This does not turn a Gitea-family status into the dedicated-reviewer gate described above. Required status contexts are not bound to their writer, so another repository writer can imitate or replace one. Relation statuses are an honest native publication surface for an unchanged branch; an identity-bound destination must carry any protected cross-repository gate.
Gitea-family approval freshness is based on changed pull-request content. The service posts a review on the exact candidate commit and checks the exact commit and tree before publication, but the provider may continue to count an approval across a commit-only rewrite with the same diff. The honest claim is therefore an exact-tree gate, not provider-enforced exact-commit freshness.
For ordinary webhook-secret rotation on the same ledger, overlap key windows, then remove the old key on restart. For a plan, bootstrap, control, repository, or target change, use a new dedicated reviewer account and a new empty inbox and ledger route. The visible review label alone cannot separate two plans because branch protection binds the account, not the label. Preserve the old ledger while its old webhook secret remains accepted; after permanent revocation, retain it only for audit.
Controller delivery
Provider deliveries are repeated, workers overlap, and a process can stop at any point around publication. The controller therefore needs a small durable record that answers two questions: who may evaluate this delivery now, and which exact result must a retry publish?
In code, DeliveryLedger
is the coordination interface for that record. It is a behavior contract rather than a required
storage format. FileLedger
is its first durable implementation; it uses ordinary files, not SQL or a database. This record is
separate from the scan ledger, which records project research, and from the
repository-owned review memory rejected in Provenance. The scanner itself remains
offline and stateless.
Before the record
The contract takes each route from controller-owned configuration, never from the request body.
It fixes the provider instance, the accepted trust-anchor set, and its signed-time rule. Before an
adapter sees a delivery, IngressPolicy
caps the exact body, header count, and total header bytes and checks the controller-recorded
receipt time against a short queue window. Only an IngressCheck that passed those checks can
enter ProviderAdapter::authenticate.
Each verifier consumes the IngressCheck itself. Authentication returns the provider facts and a
small proof: which configured anchor matched, which trust set it belonged to, an optional signed
issue time, and the replay identity. That proof also binds the controller-selected route, receipt
time, exact header sequence, and exact body. Its fields are private; an adapter can join decoded
provider facts to a successful proof, but cannot relabel its trust set, remove its signed time, or
move it to another request. The controller checks that binding, applies the route’s signed-time
rule, chooses the replay lifetime, and creates the delivery key. None of those steps trusts a
decoded body field before the signature succeeds.
The replay key depends on what the provider actually signs:
| Provider 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,
Gitea signs the raw body, and
GitLab’s signing token follows Standard Webhooks.
The matching library code is split into the
GitHub,
Gitea-family, and
GitLab verifiers.
GitLab’s legacy plaintext X-Gitlab-Token is deliberately unsupported. GitLabWebhook
authenticates the timestamp but does not choose the route policy. Ingress rejects that proof under
a replay-only route, so the signed timestamp cannot be silently discarded. A GitHub or Gitea
delivery header is useful for logs, but using it as the
durable key would let a captured signed body bypass replay protection by changing an unsigned
header.
The supported GitLab lane instead uses GitLab’s
OIDC ID-token contract through the
GitLabOidc
verifier. It pins reviewed RSA keys and binds the small merge-request hint only after the token
claims authenticate it. The Standard Webhook verifier remains available as an independent
library surface, not as this lane’s request path.
WebhookKeyring holds one through eight HMAC keys in zeroizing, redacted memory. Anchor IDs and
secret bytes must be unique. The ring owns the trust-set ID carried into its proof, so an adapter
does not relabel a successful match by hand. Each key has an inclusive start and exclusive end
time selected against controller-owned receipt time. Overlapping windows permit rotation;
removing an anchor revokes it. GitLab whsec_ tokens have a strict, secret-safe constructor.
Exact-body replay IDs do not include the matching anchor, so rotating a key cannot turn the same
signed delivery into new work.
The controller also fixes one ReplayWindow: the largest signed age any route may accept and the
largest ingress queue age. A route may require a shorter signed age but cannot exceed that fixed
ceiling. An authenticated message ID and signed issue time receive an inclusive replay end computed
from the issue time plus both fixed ceilings. Exact-body and other replay-only requests are marked
permanent because they carry no authenticated time from which safe deletion can be derived. This
choice reaches the ledger as part of AcceptedDelivery rather than being derived from payload
fields, and the file record rejects a bounded delivery from a different replay window.
These verifiers establish webhook origin and integrity, not current authorization or an exact repository snapshot. The GitHub and Gitea-family lanes add signed pull-request decoding, controller-owned refresh, merge-rule checks, acquisition, and provider publication. The GitLab lane uses a separately verified OIDC token from its protected policy job instead of the Standard Webhook verifier. Its authenticated claims enter the same delivery and replay contract. Deployment and provider-specific trust rules live in Provider-verified controls.
The full flow
The controller owns the provider route. The selected adapter authenticates the untouched headers and body before any body field is trusted. Only the resulting authenticated delivery reaches the durable record.
The first refresh resolves the event-bound provider run, not the change’s latest head. It supplies the exact repository, URL dialect, refs, commits, and trees given to the runner, plus the provider gate revision to which publication is bound. GitHub and GitLab enforce their result on that commit. A Gitea-family lane publishes an exact-commit review, while the provider owns how that review affects merging. When the plan enables external verification, the controller retains its exact plan, provider evidence, and assessment after the run. The second refresh then checks the same identity, gate revision, and current authorization before the result is staged. If the change was closed, revoked, or superseded, the controller may publish that fail-closed status; it never publishes an old pass or block as if it were still current. A provider adapter may complete a stale publication without an external write only after independently proving that its staged provider gate is no longer current.
The logical record
This is the logical schema required by the contract. It is not a file format, wire schema, or prescription for how bytes are stored.
| 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 and retained-artifact reference | 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. The separate bounded store behind a saved
artifact reference is described in Retained provider artifacts; it keeps
evidence bytes but never decides this record’s state.
The public Rust boundary has four operations. This abridged excerpt omits documentation and type bounds; the ledger module is authoritative.
#![allow(unused)]
fn main() {
pub trait DeliveryLedger {
type Error;
fn claim(&mut self, delivery: &AcceptedDelivery)
-> Result<DeliveryClaim, Self::Error>;
fn renew(&mut self, delivery: &AcceptedDelivery, lease: &DeliveryLease)
-> Result<LeaseRenewal, Self::Error>;
fn stage(
&mut self,
delivery: &AcceptedDelivery,
lease: &DeliveryLease,
publication: &Publication,
) -> Result<StageOutcome, Self::Error>;
fn complete(&mut self, delivery: &AcceptedDelivery, staged: &StagedPublication)
-> Result<LeaseCompletion, Self::Error>;
}
}
claim is the one entry point for new work and retries:
| 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.
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
Publishuntil that value is marked done.
Saving happens before external provider I/O because the record and provider cannot share one transaction. Publication may therefore be attempted more than once after an error or ambiguous acknowledgement. The adapter must make publishing the same result again have the same effect, using the authenticated delivery and evaluation ID as its repeat-safe key. A different result under that key must fail closed.
That is the controller contract. GitHub’s create-Check-Run API does not offer an atomic transaction or caller idempotency key: an accepted create with a lost reply can be retried before the first run is visible and leave a duplicate. The concrete adapter reconciles one exact visible run and rejects visible duplicates. The provider-verified-controls phase record states that limit rather than implying exactly-once.
| Stop 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. Trusted acquisition may return candidate-independent semantic-template bytes with
the roots. Each result names one plan-frozen acquisition identity; job construction matches its
producer and context, binds the exact candidate itself, combines its limits and ordering with plan
templates, and fails closed on any defect. The job also carries one bounded canonical audit value
with the exact acquired bytes, derived envelope bytes, acquisition identities, and digests for the
artifact-retention layer. It also reads the
selected bootstrap under a fixed byte ceiling and matches its digest to the frozen execution plan
before copying it into a fresh private scratch directory.
The directory holds only the copied bootstrap, canonical request files, report, and final result
record. The controller creates both output files and retains their open handles. The bootstrap may
write through the fixed path names, but the controller never reopens those names; replacing a name
therefore cannot replace the object that will be read. The child starts with a cleared environment
and closed stdin, stdout, and stderr. The report is bounded by MACHINE_JSON_BYTES; the small
result record is written last, so a missing record cannot be confused with a completed report.
Exit status and that record are checked together. Missing output, oversized reports, timeout, and
runtime tampering retain distinct fail-closed outcomes. Signals, heartbeat loss, and spawn failure
all fail closed as Unavailable.
Supervision uses pinned ProcessKit 2.2.5 with Tokio through one cross-platform path. ProcessKit selects the host’s process-tree boundary. The controller enforces a positive wall limit no greater than 120 seconds and renews the ledger before launch and halfway through each newly proven lease window, capped at five seconds between checks. After every terminal path it hard-kills the group and waits up to two seconds for ProcessKit to report it empty before reading either output. Failure to prove that drain is unavailable, not completion. This also covers a clean leader exit, so a surviving descendant cannot escape merely by letting its parent finish. A heartbeat refusal cancels the same tree and discards its output. These bounds cover ordinary supervised execution; they do not promise recovery from a host kernel call that itself never returns.
Focused tests cover wrong commits and trees, a wrong bootstrap digest, the cleared child environment, timeout and heartbeat races, descendants that remain alive after their leader exits, path replacement, and missing, malformed, and oversized output. Every provider service calls this runner only after its adapter has refreshed provider state and acquired the exact roots.
What exists now
The controller crates contain the provider-neutral identities, bounded ingress gate, rotating
key ring, signature verifiers, durable raw inbox, DeliveryLedger, FileLedger, bounded artifact
store and retrieval route, worker, orchestrator, acquisition boundary, bounded Intersphinx and
mdBook semantic-evidence producers, and supervised bootstrap runner. Focused tests cover ingress
limits and tampering, replay, rotation and revocation, file
corruption, cross-process ownership, reclaim, exact publication retry across restart, full roots,
artifact expiry, clock rollback, runner timeout, process descendants, and output replacement.
The acquisition result can also carry candidate-independent pre-scan semantic templates into the
sealed controls request; candidate binding remains inside job construction. A trusted caller can
derive source-bound routes, decoded anchors, and
rendered navigation reachability from a completed mdBook build without executing mdBook in the
controller; the built-in provider acquisitions currently leave that set empty.
Three merge-gate shapes join those pieces. GitHub uses a signed pull-request event, App refresh, strict App-bound ruleset, authoritative test merge, and App-owned Check Run. GitLab uses policy job OIDC, an enforced merge train, independently owned pipeline execution policy, and the policy job’s own result. Gitea and Forgejo use a signed pull-request event, effective protected-branch rule, dedicated reviewer identity, and that account’s approval or request for changes. All three acquire exact SHA-1 repository and action objects through the same fixed-budget protocol-v2 path, run the bootstrap, and refresh the provider gate again.
Provider-verified controls compares the lanes and links each deployment
reference. The GitLab Standard Webhook verifier remains an independent library surface; the
supported GitLab lane authenticates OIDC instead. The engine’s forge field still chooses only a
URL dialect and never authenticates a provider.
The file ledger
FileLedgerRoot prepares the ordinary-file store, and each FileLedger is one independently
fenced owner session over that root. Together they implement the delivery-record contract in
Controller delivery. This page is their storage: what one root contains, which
locks serialize it, and what cleanup may remove. The logical guarantees stay with the contract.
Layout and locks
FileLedger maps the authenticated delivery identity to a fixed lowercase digest. Provider text
never becomes a path. One controller-owned root contains fixed metadata and locks plus bounded row
files:
.amiss-root.state
.amiss-capacity.state
.amiss-maintenance.lock
.amiss-admission.lock
.amiss-clock.lock
.amiss-row-00.lock ... .amiss-row-ff.lock (created only when used)
<delivery-key>.state
<delivery-key>.report (only while a result needs it)
The maintenance lock is shared by ordinary row work and exclusive during cleanup. The admission lock serializes capacity recovery, reservation, and creation of a new row. The clock lock serializes durable high-water updates. The first byte of the delivery digest selects one of 256 stable row-lock files; a shard collision may serialize unrelated rows but cannot let two processes win one transition. These fixed names avoid one permanent lock file per delivery.
Frames and replacement
Root metadata is itself a versioned, checksummed frame. It fixes the lease duration, maximum record count, and signed-age and queue ceilings for every process using that root, and stores the highest trusted controller time the ledger has seen. Opening the same root with a different lease, record cap, or replay window fails.
A separate checksummed capacity frame holds the record limit, a slot count that never understates use, and at most one pending row key. Before a new row is written, its slot and key are saved; after the row is written, the pending key is cleared. If the sequence is interrupted, the next new-row admission or full cleanup checks that exact row path and finishes the update. Before cleanup deletes a batch of ended rows, it saves one cleanup marker; after deletion it saves the exact count once. An interrupted batch leaves a safe upper bound and is reconciled by the next root open or explicit cleanup. Ordinary admission reads the bounded capacity frame and requested row; it does not walk the root directory. Once the cap is full, a new identity fails before its state file is created, while an existing row can still renew, save, publish, and complete. Operators must size the cap to include permanent replay markers.
Opening FileLedgerRoot validates the complete root, runs cleanup, and prepares the store once.
Creating a session chooses a fresh owner identity without scanning or cleaning the root.
FileLedger::open remains the convenience form of opening a root and immediately creating one
session, so it still performs the startup scan.
The root metadata written by v0.9 is validated and upgraded in place, and its existing rows seed the first capacity frame. The migration changes root-level bookkeeping only; row and report bytes are unchanged, and the older row schema remains rejected. After the upgrade, a missing capacity frame or an unmarked count disagreement with the decoded rows is corruption. Stop every v0.9 controller process before the first upgraded open; the metadata upgrade is one-way.
The state file is a versioned, length-delimited, checksummed frame containing canonical JSON and is
capped at 128 KiB. The reader accepts only its current row schema. 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.
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 and
GFM test
suites plus the MDX grammar’s own tests. The pin is a checked-in manifest recording node
counts, extraction results, and byte positions for every test case. A parser change that
moves any of those moves the manifest, and review sees the diff.
amiss-scan is the evaluation itself: discovery, resolution, correlation, the
base-versus-candidate comparison, policy, and report construction. It is a library that
does no I/O beyond the store handed to it. 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 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 root api/ specialist and
controller/ crates sit outside that
graph. They are unpublished and nothing above depends on them. amiss-api normalizes bounded
Rustdoc JSON into semantic records without entering provider binaries. The controller crates keep
provider, HTTP, storage, credential, Git acquisition, and runtime dependencies out of the scanner.
amiss-controller owns the provider-neutral orchestration and supervised bootstrap contracts;
amiss-controller-git owns bounded protocol-v2 acquisition; and amiss-controller-service owns
the bounded webhook, synchronous evaluation, and authenticated artifact endpoints, durable raw
inbox, and worker. Small provider crates and service binaries add the GitHub App Check Run, GitLab
merge-train policy job, and Gitea or Forgejo dedicated-reviewer gates. All durable state uses
ordinary files rather than SQL or a database.
Controller delivery defines the neutral record and retry rules. Provider-verified controls compares the concrete flows and links each provider’s setup and trust boundary.
Inside an engine run, the stages form a line:
Each stage charges resource counters at a defined admission or observation point, and a crossed ceiling is a refusal, never a repair. Not every counter is a pre-work bound: document bytes are admitted before parsing, while parser node and nesting totals are charged after the grammar returns. Security model records the CPU-boundary limitation that follows from that ordering. Subject to those declared inputs and boundaries, the report is a pure function of the two snapshots and the invocation.
Edge cases and divergences
The edge cases in the suite are where the contract’s exact wording gets earned. A sample:
Paths are bytes, so été.txt resolves without any Unicode normalization, and two names
that differ only in case are two different files even on a filesystem that would merge
them. A directory link resolves the same way through a commit and through the staged
index, which sounds obvious until you know the index stores no directory entries at all
and containment has to be proved from sorted path prefixes. guide.md/ with a trailing
slash fails as a type mismatch while guide.md resolves right next to it. %252F in a
link decodes once, to the literal text %2F, and never becomes a second path separator.
Hostile input gets the same rules with more suspicion. A document path carrying ANSI color
codes, a terminal bell, and a forged CI command prints harmlessly in the human output and
survives byte-for-byte in the JSON. A tree entry named with bytes that are not UTF-8 is a
scanned document whose path travels as hex, not a refusal and not a silent drop, while a
name the path grammar itself rejects, a backslash or a dot segment, is an
UNREPRESENTABLE_PATH refusal that disclosed those exact bytes. A five-thousand-byte path
is a reported limit crossing carrying both numbers. A tracked file whose object is missing
from the store refuses and names the document, instead of guessing about content it
cannot see.
The forge dialects pin the URL spellings the forges’ own browsers emit and nothing
looser. GitLab’s legacy pre-separator form still redirects in a browser and is foreign
here, as is /-/raw/; a GitLab project literally named - could never be told apart
from the separator, and GitLab reserves the name anyway. Gitea’s untyped src/<ref>/
form, which some tooling still generates, is foreign because the typed src/branch/
spelling is what the forge emits. A gitea tag link is out of version scope even when its
segments spell the candidate branch exactly, because no tag is a trusted ref. The
Bitbucket Cloud source form gives the commitish exactly one segment, so a candidate branch
containing / is not guessed into its filepath. Bitbucket Data Center keeps the path before its
revision query, so only an exact full ref or object ID in at=, or its path-bound until history
pair, selects a version; abbreviated IDs and extra parameters remain version-scoped. A literal
installation context without a reserved projects or users segment may precede the route, but a
raw route is foreign. The
line-anchor grammars do not leak either: #L10-20 selects lines only under gitlab, #L10-L20
only under github and gitea, #file.rs-10 only under bitbucket-cloud when the basename agrees, and
#10-20 only under bitbucket-data-center.
The parser pin records its known differences instead of hiding them. Measured against the
pinned grammar bundle and against GitHub’s own rendering, exactly one difference
affects link extraction: [link[^1]](#), a footnote call inside a link label, which the
pinned Rust parser does not turn into a link. A reference written that way goes unseen.
That is under-reporting, the safer direction to fail, and it is written down in the corpus
notes rather than waiting to be discovered. Two upstream test documents make the parser
panic; the engine catches both as PARSER_PANIC, and both live in the corpus as regression
tests. The GFM spec text says ftp:// should autolink where the pinned bundle and
GitHub’s renderer disagree; the bundle wins, and the corpus records why.
One more, for flavor: the object store re-hashes everything with SHA-1 collision detection, and the suite proves that the public SHAttered and Shambles collision files cannot even be framed as Git objects without breaking the very property the detector checks. Reachable in code, unconstructible in practice, and tested as such.
What Amiss is not
Amiss does not read your prose. A hash can prove that a file changed; it cannot prove that a sentence became false, or that anyone reviewed it. The investigation behind this tool spent a long time on designs that pretended otherwise. The strongest lesson in that record: observation, acceptance, review, and trust are different facts, and dressing one up as another rots all of them. So Amiss reports structure. This link resolves or does not, these bytes changed or did not, this paragraph moved or did not. Judgment stays with people.
It is not a link checker in the usual sense. Live-URL checkers query the network and decay with it; the scanner engine never touches the network and only ever speaks about one repository’s own files in two exact snapshots: a base commit and either a candidate commit or the staged index. Provider controllers can acquire the exact repository state before invoking that engine, but they do not make live URLs an evaluation input. Amiss is not a style linter either: it has no opinion on headings, tone, or wording, and no rule engine to hold one.
It is not a documentation-coupling system with memory. Tools in that family, Fiberplane’s drift, Swimm, and the ledger design this project itself rejected, record what they blessed and react when reality drifts from the record. The rejected design is described in Provenance. The shipped scanner remembers nothing, which removes the migration, merge-conflict, and trust-on-edit failure modes wholesale, at the price of answering a smaller question.
It answers a heading anchor against the slugging rules of the renderers it models, and reports one that no rule publishes as a missing target. What it cannot say is whether the section behind a resolving anchor still means what the prose claims, and a renderer outside What twelve renderers call a heading is outside the answer. A recognized numeric line fragment is narrower still: Amiss can select and compare those exact bytes, but cannot tell whether they still express the idea the prose intended. A relative destination the tree does not hold is asked again under the spellings a documentation router serves, which reaches a file the tree already holds or nothing at all. A leading slash can additionally resolve from sealed candidate build evidence only when an exact route and optional anchor map to a scanned source; everything else stays outside the answer. It validates no code symbol, live URL, or other repository. Those checks belong to a layer holding the right information: the site generator owns its permalink scheme and the language server knows its symbols. Where a supported construct reaches one of these boundaries, Amiss records the unsupported or out-of-scope semantics instead of guessing, because a guessed pass looks exactly like a real one until it burns you.
Reading paths out of prose was measured rather than assumed, and it is not close. Across this
book, ruff, and uv, the strongest available signal is a path-shaped token inside a code span,
and 55 to 85 percent of those name nothing in the tree; requiring a slash lowers the rate
rather than raising it. What the non-resolving pile holds is documentation’s own teaching
examples. ruff’s twenty-two most frequent are main.py, a.py, b.py, mypackage/__init__.py
and their kind, 564 mentions that were never references and can never be fixed. A tool that
reported them would file more than a thousand rows against ruff to surface the ten real missing
targets the explicit checker already finds, and it would be worst exactly where documentation is
densest, because the pages that teach with examples are the pages full of filenames that do not
exist. So the engine reads link syntax and stops.
And it accepts no configuration that would let a repository weaken its own check. No suppression comments, no severity downgrades, no hooks. The absence is the point.
Against the alternatives
The predecessor investigation compared the design with several neighboring approaches and recorded where each one wins.
Swimm 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 and its relatives) prove more than Amiss does about the lines they execute, and nothing about any other line. Regeneration pipelines eliminate drift on derivable content and say nothing about hand prose; user zero’s stale-generator story in The evidence base shows regeneration passing forever on wrong output. Freshness dates are universal and free and gate on the calendar rather than on change. Each of these is a fine layer. None of them answers the question Amiss answers, and Amiss does not answer theirs.
Amiss and link checkers
Link checkers and Amiss solve different halves of one problem, and the halves compose. A link checker asks whether destinations are alive, most valuably the external ones: it fetches URLs, follows redirects, and reports the dead. Amiss asks whether the repository agrees with its own prose: it resolves every in-repository reference against an exact Git snapshot, compares two snapshots to see what moved under what, and gates the change that broke the agreement.
lychee is the strongest of the checkers and the one worth
comparing against honestly. It is fast, async, and reads Markdown, HTML, and
reStructuredText; it checks external URLs, which Amiss never fetches by design; it
checks local file links, and with --include-fragments it verifies heading anchors, which
Amiss also does, against 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 after the format’s own decoding, the address a fetcher would request, and the external plan turns a written report into the delta a checker actually wants: the destinations this change introduced, not the whole corpus every run.
amiss check --repo . --object-format sha1 --base "$BASE" --candidate HEAD \
--profile observe --format json > report.json
amiss external-plan --report report.json --format json |
jq -r '.payload.introduced[].destination' | lychee -
Checking only what the change added is what keeps a checker tolerable in a gate: the author can fix a link they just wrote, and the pre-existing corpus rots on its own schedule instead of failing every pull request.
What a link checker cannot see is change. It examines one state of the world, so it can say a target is missing but not who removed it, whether it was missing before your pull request, or that a target still resolves while its content moved out from under the paragraph citing it. Those questions need two snapshots and exact comparison, and they are where Amiss lives:
| 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.
The evidence base
The design was not reasoned from an armchair. Before any code, one real repository was audited end to end and used as the requirements generator. The project record calls it user zero, and it is public: spec_to_rest, a Scala compiler with machine-checked proofs, three code-generation targets, a published docs site, and 22 CI workflows. It already ran roughly a dozen hand-built drift defenses: transcluded snippets, executable CLI examples, five golden-file suites, a proof-extraction diff gate, a link checker. A repository that tries that hard and still drifts tells you what a tool must actually do.
The removed working dossier remains available at the immutable pre-extraction commit: the repository audit contains the observed cases, and the experiment index points to the recorded measurements summarized here.
The audit found seven live drift classes despite all those defenses. A few, concretely:
- The architecture page said ten workflows; the tree had 22. It named a workflow file that had never existed. Three different module counts coexisted on one page.
- The CLI reference omitted a public subcommand and documented a three-value exit-code contract while the code used four. The executable snippets on the same page were all green, because examples protect only the paths they execute.
- A published OpenAPI file, claimed identical to compiler output, differed from the current golden by five stale lines.
- The railroad diagrams regenerated on every docs build, from a second copy of the grammar embedded in the generator script by hand. Regeneration succeeded forever while faithfully reproducing a stale input. Freshness of a generation step proves derivation from the step’s input, not truth.
- CLI transcripts kept passing against a stale compiled binary and only failed when someone rebuilt it. A green check against the wrong environment is worse than no check.
- Hand-written counts (“ten workflows”, “23 theories”) were wrong in every place they appeared, because humans cannot maintain embedded aggregates.
Measurements set the tool’s scale expectations. Conservative discovery found 109 documents. Of 55 same-repository GitHub links, exactly two were broken, and the other measured explicit references all resolved under their real semantics. Replaying history showed the surviving reference graph would have produced 773 target-impact events across 393 first-parent commits, which is reviewer workload, not 773 defects, and it is why a file changing under an unchanged paragraph never blocks. And the experiment that shaped the architecture most: a single committed state file, updated from branches, conflicted in 0%, 18%, and 99% of trials as update counts per branch grew. That number is a large part of why the shipped scanner keeps no state at all.
Two observed conditions draw the sharpest boundaries. A page that was edited the day before the audit was already wrong when edited, so any scheme that trusts an edit blesses false prose. And every mechanism that let a person clear findings in bulk was, in the audit’s words, the gate’s cheapest bypass. Both conditions killed the ledger design described in Provenance, and both explain why the scanner only ever reports what two trees say.
Provenance
Amiss started life as a different tool. The original design was a review tracker with
memory: a committed ledger of what had been checked, paragraphs trusted when edited, an
ok command that recorded a human’s acceptance, and a refresh job that kept the ledger
current. Point it at a repository and it would tell you which paragraphs had gone stale
against the code they cited, and remember your answer.
The pre-implementation review killed that design, and the reasons survived a second, adversarial review. Identity rules that were safe for automatic observations were unsafe for recorded human attestations sharing the same storage. A committed ledger file conflicted between branches at rates that would have made merge queues the real product. Trusting a paragraph because it was edited blessed pages that were already wrong.
The deepest cut was simpler than any of those. The system’s central promise, that a recorded acceptance meant a human had checked the prose against the evidence, is not something any mechanism can make true. The project’s own experiments provided the counterexamples: of five replayed real-world cases, three had their paragraph edited while the reference stayed broken. The numbers and stories behind that verdict are in The evidence base.
What survived is the part that never needed memory. A link either resolves in a tree or it does not. Bytes either changed between two commits or they did not. A paragraph either moved with the file it cites or it did not. Those are pure functions of two snapshots. They need no ledger, no lock, no refresh, and no belief about anyone’s intent, and every one of the rejected designs had agreed on them. The v0 scanner is that surviving part, built alone, under the review’s discipline: fail closed, report every count, guess nothing.
The full investigation, market survey, adversarial reviews, and experiment data remain in
the repository’s history as an
immutable dossier.
The machine-readable contracts, schemas and canonical examples ship in spec/, led by the
current report contract. Where this book and the
dossier disagree, the shipped code and its tests are the authority for what the tool does;
the dossier remains the authority for why.
Development
The toolchain version is pinned in rust-toolchain.toml, today at 1.97.0, unsafe is
forbidden in every crate, and the lint table denies panics, lossy casts, wildcard
matches, and undocumented errors. The version in that sentence is this book’s first live
value claim: the definition below pins the pinning line itself, so a
toolchain bump that forgets this page fails the repository’s own gate with the corrected
expectation in the finding.
Gate-tool versions live in one place, the workspace.metadata.tools tables of the root
manifest. Runtime consumers use Cargo’s projection directly: the CI tools composite and
ratchet hooks query cargo metadata with jq, GitHub’s hashFiles keys the shared tool cache,
and the agent lanes install through the composite. A documentation-contract test parses the
manifest independently and refuses any workflow spelling a declared tool at another version.
Bumping a tool is one edit.
Hooks run through prek: formatting and the cheap checks
on commit, then Clippy with
warnings denied, the full test suite, cargo deny, cargo shear, and a
similarity-rs twin-function gate on push. The tool
compares functions within one file, so the gate also concatenates the deliberately parallel
provider transports, lane-test harnesses, service runtimes, and verification files. It maps
those generated lines back to their source paths and compares stable pair identities with the
base Git tree. Every candidate edge must already exist in that base set, so removals pass while
new relationships, including an equal-count remove-and-replace, fail.
Main and merge-queue CI cache the derived base manifest by tree and policy identity. Pull requests
rescan so their private cache scope cannot shadow the default branch; any missing or invalid cache
is likewise regenerated from Git, so no mutable baseline or allowlist lives in the tree.
A last push-stage hook
runs cargo-sweep over target/, dropping artifacts and
incremental sessions older than two days; cargo never collects superseded builds, and this
repository mints a fresh copy of every test binary on each lockfile or version change. Five days
held 86 GB and the sweep reclaimed nothing from it, because every generation was inside the
window. The hook
is a no-op where cargo-sweep is not installed. 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.
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:
the compiler-output specialist under api/ and the HTTP, provider API, Git acquisition,
credential, storage, and service-runtime crates under controller/ are unpublished.
deny-engine.toml drops them from the graph and then bans the network and async stack, so what an
amiss user downloads cannot acquire it. The prek hooks run the first pair and Linux CI runs both.
The macOS and Windows jobs also run the controller tests, including the cross-process file stores,
provider authentication, worker, and supervised-process cases. The supported service deployments
are documented in
Provider-verified controls.
Tests answer to a house rule called the teeth check: important tests are exercised against deliberately broken behavior before they are trusted. The mutation lanes publish a non-gating measurement of that property, in three sizes: per pull request, per push to main, and on demand, each on its own trigger so no lane ever shows as skipped beside another’s run.
Every pull request measures only the mutants the change itself reaches, over shards counted
from the mutants the diff actually reaches rather than from a guess, because listing them needs
no build. That lane asks the whole workspace whether each mutant lives, at about twenty seconds
per mutant. The shards used to pay a worse floor, a cold workspace build plus a baseline test
pass repeated in every shard; now each shard restores the build cache that the baseline job
saves on every push to main, and skips its own baseline because ci proves the same commit in
the same run, so what remains is the build delta against the last merge and the mutants
themselves. A release pull request measures its version and generated-file diff like every other
pull request. The code it packages was already measured before each change reached main; measuring
the accumulated release again would duplicate that work and eventually exceed the bounded PR lane.
The sweep over every mutant in both workspaces runs only when someone asks for it, through
workflow_dispatch. It is split across shards sized from the mutant count rather than a fixed
number, it takes tens of minutes, and it exists to find gaps in code that no longer changes,
which is not something a release should pay for. Fixture crates are excluded, because code that
exists to be exercised by its callers says nothing about the tests. Unlike the smaller lanes it
runs each mutant against its own package’s tests, which is what makes it affordable and also
means a mutant that a sibling package covers is reported as surviving. Its output is a list to
verify, not a verdict.
The sweep of 2026-07-28 is the reading to compare against: 6,523 mutants, 4,103 caught, 1,335 missed, 1,085 unviable, no timeouts. Roughly half of those missed are the scoping artifact above, measured at 43% on one file and 62% on another, so the number of real gaps is nearer seven hundred. A later sweep that misses far more has either lost tests or gained untested code, and the point of writing the figure down is to be able to tell which.
None of the three gates a merge and none certifies a global mutation threshold: a surviving mutant is a place where a lie would go unnoticed, to be judged against whether the perturbed value is observable through real behavior, not a score to raise.
Three agent lanes sit beside the gates, none of them gating, written as
gh-aw workflows whose agents hold no write token at
all: they run read-only on DeepSeek inside the repository’s runners, and a separate harness
job posts what their structured outputs request, nothing else. A new issue gets its premise
checked against the tree before a maintainer reads it, whoever opened it. Every push to a
same-repository, non-draft pull request gets one consolidated review, summary and
line-anchored comments in a single card, and a review can be dispatched by hand for any PR
number. And /oc in a comment summons the agent, which can answer, fix, and propose a pull
request through the same structured channel. What the lanes read is a claim under test,
never instructions; only the mention lane takes tasks, and only from a collaborator’s
comment. Each comment carries its run link, and the full agent log survives as a run
artifact.
The parsers sit under a vendored test corpus, pinned by digest, whose manifest records node
counts, extraction results, and byte positions for every case from the upstream CommonMark,
GFM, and MDX suites; the
corpus notes document every
known difference. Scanner parsers that take untrusted bytes have targets under fuzz/.
The controller fuzz package
signs generated provider requests before varying their facts, so its account-free targets reach
the provider identity and binding checks. Both suites carry committed seeds and a
nightly coverage-guided run.
The scanner runs on its own repository under --profile enforce in CI. This documentation
passes through that same gate: every relative link in this book resolves in the tree, or
the pull request that broke it fails.
Every pull request also packages what a release would upload. cargo package over the
publishable members resolves their siblings through a temporary local registry and builds every
tarball, so a file dropped from a package, a path dependency missing a version, or a new crate
its dependants cannot see fails on the pull request instead of halfway through an upload that
cannot be taken back. It is cargo package rather than cargo publish --dry-run because the
dry run prefers a version already on crates.io over the tree, which makes it blind to exactly
the crate a change is adding.
Releases are automated. A bot keeps a release pull request current with the version bump,
changelog, and exact Action-dispatch ref. Merging it publishes the crates and source tag while
the GitHub release remains a draft. The release workflow then assembles the immutable
action/vX.Y.Z tree and exercises both that exact tree and the source-tag dispatcher on Linux,
both macOS architectures, and Windows. Only a green smoke matrix advances the stable major ref
without rewriting history and makes the release public; prereleases never advance the major ref.
The same gate governs the release assets: the per-platform engine and prober binaries, their
SHA256SUMS, and the sigstore bundle attesting that file attach to the draft, so a release
that fails the matrix never publishes a binary.
If a forge outage leaves that pull request stale, manually dispatching the
release automation on main refreshes its metadata
without running the publishing job; crate publication remains restricted to pushes on main.
Security checks layer in CI as well. Dependency update PRs arrive with a cooldown, a
weekly advisory re-check runs against a fresh database, and
CodeQL covers both the Rust and the workflows.
Scorecard, 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. Future work and its
entry conditions live in the Roadmap.
Supported surface
| Area | Current contract | Implementation anchor |
|---|---|---|
| Command | amiss check compares a base commit with either a candidate commit or the staged index, amiss fix applies the staged evaluation’s proof-gated fixes to the working tree, amiss claim authors a value claim proven against the working tree before it is printed, amiss policy-include authors one validated exact-suffix policy row and can preview its staged matches, amiss record-set converts normalized specialist rows into a checked self-asserted semantic template without opening a repository, amiss adopt mints an adoption-debt snapshot from a commit pair’s eligible findings, amiss external-plan derives the delegated-evidence destination delta from a written report without opening a repository, amiss external-assess judges that plan against a producer’s observations under a fixed offline policy, amiss render projects one validated report without evaluating it again, amiss refs returns the exact candidate occurrences that refer to one repository path, amiss --help prints that closed grammar, and amiss --version reports the binary’s version, with its engine digest when the binary can read itself. The command grammar is closed at those twelve forms. | CLI parser |
| 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 |
| Documents | Built-in discovery covers Markdown, GFM, MDX, AsciiDoc, reStructuredText, six extensionless Markdown basenames, and two plain-advisory basenames. Repository policy may add exact paths or trees narrowed by one exact suffix, each optionally bound to one built-in grammar, without installing another parser. | Classifier |
| References | Relative repository paths and same-repository GitHub, GitLab, Gitea-family, Bitbucket Cloud, and Bitbucket Data Center URLs are resolved under their declared dialect. Full immutable IDs are accepted only in the run’s object format and resolve against that exact commit when every required object is already available under the declared Git roots and budgets. A complete local walk may prove a missing historical path; unavailable objects retain the exact ID and path as unsupported historical scope and enter the provider-evidence plan, while the engine never fetches them. A relative destination the tree does not hold is asked again under the spellings a pinned documentation router serves, which can only reach a file the tree already holds. A missing candidate path carries evidence of a unique unchanged relocation when the base and candidate entries have the identical Git mode and object ID, but never a repair. Numeric line fragments select and compare an exact inclusive byte range, and a heading anchor resolves when any of twelve pinned renderer rules would publish it or the document declares the identity itself, in raw HTML, in an attr_list block, or in the MDX comment Docusaurus uses. Option-free local AsciiDoc and reStructuredText includes expand recursively from the frozen scanned set, and literal includes never parse their target as markup; dynamic, selected, nested-context, unavailable, and AsciiDoc build-state-dependent absence retain conservative boundaries. Unsupported shapes remain visible in the report, and a delegated destination is recorded where it is seen, never fetched. | Resolver |
| Policy | .amiss/scanner-policy.json may expand discovery, own exact visible source, tree-inventory, and inventory-count projections, and raise the disposition of missing targets, target-type mismatches, and invalid references. It cannot downgrade or suppress a finding. | Policy application |
| Reports | Machine output uses the frozen report envelope and payload contract. Exact findings remain the evidence surface; engine-grouped Fix, Check, and Existing feedback is its review projection. The wire is versioned by the in-report compatibility field, not by the engine release; it reads 1, frozen and additive within the major, with the retained first frozen example holding every later schema to that promise. | Current schema |
| Semantic evidence | A separate 16 MiB envelope binds one external producer and at most 100,000 canonical observations to the exact candidate and optional source report. Every sealed value also carries the controller-plan-owned context digest its producer must reproduce. Unknown observation kinds are inert. Compiled consumers resolve unique prefixless Sphinx labels; exact built-site routes, anchors, redirects, and navigation; and exact values, sorted rows, or counts from complete record sets. The public check command can bind one candidate-free local template after resolving the exact commit or staged-index identity, but that caller-selected input remains self-asserted. The offline record-set form can assemble such a template from strictly validated normalized rows while leaving specialist digests, completeness, and authority as caller assertions. An isolated operator binary normalizes bounded, format-matched Rustdoc JSON into a complete record set of root-crate public free, inherent, and trait function declarations without invoking Cargo or entering provider binaries. Provider plans produce the bounded operator-local or cached Sphinx inventory set. Trusted acquisition adds strictly checked candidate-independent template bytes under a unique planned acquisition identity; the controller binds the exact candidate and constructs a bounded audit value from the source and envelope bytes. The controller can derive routes, anchors, and navigation from a pinned mdBook renderer context and caller-opened completed HTML output, bound to an exact configuration path, publication prefix, locale, and version. The GitHub provider lane can acquire a plan-frozen workflow artifact for the exact candidate; the other provider lanes expose no workflow-artifact source. | Contract, Rust producer, inventory producer, mdBook producer, and consumer |
| Publication audits | Closed digest-bound contracts model an operator-owned publication plan, one provider-normalized successful-deployment receipt, and a conservative offline matched/refuted/unproven assessment. They bind the accepted report and exact docs, target, completed-site, product, deployment, workflow, producer, and evaluator identities. The controller validates that a complete chain describes its exact scanner report and replays to the supplied assessment. Its artifact store retains and reopens the exact report, plan, optional evidence, assessment, digest set, and verdict as one immutable evaluation-bound record. No command or controller lane acquires, stages, or publishes it yet. | Publication audits |
| Locale coverage audits | One closed digest-bound plan binds an accepted report and exact docs candidate to a site, locale pair, independently selected producer, operator-owned coverage/fallback/optional target-lineage policy, and an optional immutable product resource reused from publication audits. Evidence carries independently complete source and target inventories with independent nullable product receipts; every target page is target-owned with nullable exact source lineage or declares an exact fallback class and source digest. The offline assessment reports proved missing/orphan pages, fallback status, current/stale/unproven target lineage, and matched/refuted/unproven product identity for each side. Only an exhaustive clean selected comparison matches. Exact lineage and product equality prove only the named digest relations, never translation quality or semantic equivalence. Command and controller intake are not built yet. | Locale coverage audits |
| Cross-repository relations | The provider-neutral controller atomically freezes operator-owned two-subject relations, one opaque pair/release/workflow coordination identity, and all four exact base/candidate commit/tree identities without inferring intent from timestamps. A pure admission law assigns the first exact transition a fence, preserves identical work as a duplicate across either trigger role, rejects stable-identity rebinding, and advances the fence when a different coordination supersedes pending work. A bounded file-backed store applies the same law under a cross-process lock and atomically committed hash-chained journal; it serializes concurrent admission, recovers an uncommitted append after restart, and refuses exhausted capacity or missing, shortened, mutated, or rebound committed state. Exact historical retries retain their first fence without rolling current work back. The existing strict Git transport acquires each subject into a physically independent root under per-subject and aggregate streaming object/byte limits, rechecks every commit-tree binding, and returns the complete relation as unproven on any unavailable subject. A closed digest-bound plan retains the accepted report digest, trigger role, relation context, coordination identity, shared projection, human-readable repositories and selectors, and all four exact snapshots without copying credentials into the wire. The provider-neutral Git layer binds that plan back to the frozen transition and projects exact blob-line, named-region, or tree-path sources from all four acquired snapshots under joint record/byte budgets; unavailable sources produce null slots, while record values and sets await a trusted snapshot-bound producer. A separate plan-bound receipt gives each role independent nullable base/candidate slots; a present slot is only a projected-value digest and exact byte length, while null cannot claim emptiness or inequality. The replayable offline assessment binds the report, plan, optional evidence, and evaluator, then symmetrically classifies the complete equality transition as aligned, introduced drift, pre-existing drift, or resolved drift; absent, foreign, misrouted, or partial evidence remains unproven. Before immutable restart-safe retention, the controller reopens the accepted report, binds its repository, target, coordination, and snapshots to the trigger role and frozen operator transition, and independently replays the plan, optional evidence, and assessment. Pure status preparation requires both complete registered subjects at their still-current candidate commits and freezes only configured destinations under the pending fence. A second pure transition replays the retained audit against that pending work, freezes the exact target/audit record, resumes an unfinished exact retry, rejects immutable-field substitution, and completes the exact record idempotently. The same bounded journal commits status stage, per-destination acknowledgement, and completion actions under the scheduling lock, verifies the retained artifact before first stage and unfinished replay, and retains compact digest bindings instead of duplicating provider configuration or credentials. A provider-neutral delivery claim selects the oldest unresolved fence for each stable destination, reacquires its exact registry and artifact record, and holds one of 256 deterministic OS-lock shards across provider I/O. Dropped claims recover without durable mutation; a newer coordination cannot pass an unresolved older destination; unrelated shards remain parallel; and the final acknowledgement completes the batch, including recovery from failure between the two journal actions. The outbox itself performs no provider call. The GitHub installation and Gitea-family clients resolve exact registered branch heads. GitHub reconciles App-owned relation checks, while Gitea-family reconciles dedicated-reviewer commit statuses with its documented writer-binding limitation. A GitLab adapter resolves only the ephemeral candidate of an authenticated active policy job and returns the exact staged relation decision after a final live refresh, without making a provider write. The shared service loads one bounded strict-JSON operator registry and derives repository hosts from provider instances. An immutable generic router binds every required credential identity to one caller-owned authority under its exact provider and integration and rejects missing, unused, repeated, or rebound rows. Coordination admission consumes one authenticated delivery and accepts only an opaque operator identity for a relation owned by its exact trigger set. Provider binaries do not construct or install those values yet; snapshot acquisition and live lifecycle integration are not built. | Cross-repository relations |
| GitHub convenience 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 and runtime |
| 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 and optional plan-frozen workflow artifacts, runs the sealed bootstrap, and publishes on GitHub’s test-merge commit. GitHub.com and compatible GHES releases are supported. | GitHub setup |
| GitLab provider 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 |
| 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 service operation | Every service has an offline configuration check, separate liveness and readiness, fourteen fixed label-free counters, redacted lifecycle events, and graceful drain. The listener and its operator endpoints remain private. | Service operation |
Repository form is deliberately closed too. The reader accepts the non-bare checkouts: a
primary checkout whose .git entry is a real directory, a linked worktree, and a
separate-git-dir checkout. The .git file forms resolve through one bounded gitdir:
indirection plus at most one bounded commondir hop, symlinked targets are refused, the
index always reads from the worktree’s private directory, and depth is structural rather
than configured. Bare repositories stay unavailable directly, though a bare main’s linked
worktrees read through their commondir, and alternate object stores are not consulted. The
repository boundary
and its boundary tests
pin that behavior.
The supported reference surface is intentionally smaller than “every path-like phrase in prose.” Bare filenames in ordinary text are not inferred; raw HTML and MDX code regions are opaque; leading-slash site routes without sealed build evidence, code symbols, live URLs, and references to other repositories are not validated under those systems’ semantics. Their visible boundary behavior is described in Discovery and Resolution.
Trust surfaces
The repository contains strict parsers and canonical writers for evaluation, snapshot, and
external-control requests, plus evaluation logic for organization floors, adoption debt,
waivers, trusted time, and execution constraints. The evaluation identity separates the
candidate ref used for same-repository URL resolution from the protected target ref used by
the branch-scoped floor, trusted-time, debt, and waiver gates. The public command still
supplies all five external controls as absent and has no target-ref option; its repository and
candidate-ref fields remain caller assertions. check may add one caller-selected semantic
template, which does not change that trust level. The
forge field selects a URL dialect, not an authenticated provider. Compare the
request schemas,
strict parsers,
pipeline shell, and
CLI wiring.
amiss-bootstrap now has a sealed engine path. It bounded-captures the three request files,
requires their canonical forms, a complete repository/dialect/ref identity, and coherent
commit-pair materialization, matches the embedded execution constraint and trusted-time
provider/run tuple, checks that both requested commits were pre-acquired, validates the action
tree and runtime closure, and then sends only a closed evaluation/snapshot/controls frame over
stdin to the verified engine. The child receives the repository as its fixed working directory,
a cleared environment, one private engine argument, and no caller-selected engine command.
Report acceptance rejects an unavailable hybrid and
binds the requested profile, both commits, candidate and target refs, candidate identity,
provider run and trusted instant, the exact presence, digest, and trust source of the organization
floor, debt snapshot, and waiver bundle, and the execution constraint’s digest, trust source,
and recomputed semantics, plus every supplied semantic envelope’s payload and producer/input
identity after its plan-owned context matches. It likewise recomputes the trusted-time statement’s semantic digest and
requires the sandbox provenance to remain self-asserted. The wire crate exposes checked
constructors and canonical writers for the execution constraint and trusted-time statement, and
the controller uses them when it derives a sealed job. The source-built amiss-constraint
companion reads an exact local action commit and bootstrap, derives the redundant execution
fields, validates the dependency locks and selected runtime closure, and writes the canonical
constraint without fetching or authenticating either input. The release workflow builds the
sealed action and bootstrap path; it does not publish the companion or provider services. The
published composite Action still launches amiss directly, while separately operated provider
services acquire their inputs and invoke the sealed path. The distinction is visible in the
bootstrap entry point,
constraint producer,
release assembly, and
Action execution.
The unpublished crates under
controller/ define the
provider-neutral identities, bounded ingress, rotating verifier keys, durable raw inbox,
delivery record, retained artifact store, worker, orchestration, acquisition, and supervised
runner contracts. Its state uses checksummed ordinary files with fixed capacity and atomic
replacement, not SQL or a database. Controller delivery records the cross-process
ownership, heartbeat, replay, and exact-publication retry rules; authenticated report and
assessment retrieval is defined by Retained provider artifacts.
The provider crates add signed-input decoders, controller-owned credentials and API clients, strict merge-rule authorization, fixed-budget protocol-v2 Git acquisition, and provider evidence. GitHub uses an App-bound required Check Run on the test merge. GitLab uses an independently owned pipeline execution policy job on an enforced merge train and authenticates that job through OIDC. Gitea and Forgejo use a protected approval restricted to one dedicated reviewer. The workspace keeps those adapters separate instead of turning provider differences into one closed provider enum. Provider-verified controls compares the supported lanes and links their exact setup, retry limits, and trust boundaries.
All three service binaries share an offline configuration check. It validates local configuration and named trust inputs before creating the runtime or touching provider and mutable service state. Once running, their shared service operation contract separates liveness from readiness, keeps metrics and lifecycle events bounded, and drains admitted work without losing a durable webhook backlog. These are deployment tools, not retained live-provider evidence.
Local and convenience-Action reports still describe repository policy with no outside authority
consulted. Their external controls are absent, an optional local semantic template has no outside
authority, and sandbox assurance is self-asserted. A report
produced through a provider lane can contain verified external controls, but the report does not
authenticate who supplied them and gains no provider_verified field or signature. Provider
evidence lives in the App-owned Check Run, protected policy job, or dedicated review and the
matching merge rule. See
Controls and policy and The report for the exact distinction.
Keeping this page honest
Links from factual prose to the implementation are deliberate. The repository’s own Amiss scan makes a changed dependency under unchanged prose visible for review.
The mechanical claims are generated, not maintained. Default dispositions in Profiles and findings and resource ceilings in Limits and refusals come from the Rust constants through a test, so changing a constant without the book fails CI. The same documentation contract test finds every public schema-backed example, validates it against its schema, and feeds it to its owning typed reader; a contract without a registered reader fails CI too.
The examples execute. The report’s readable form passes the strict JSON reader, and its canonical bytes clear the wrapper acceptance law end to end. The commit and staged-index identity preimages reproduce the production digest chain in the identity golden test. The published semantic corpora drive their live code paths: frontmatter vectors through the recognizer, correlation vectors through the intent projection, and governed-definition vectors through report construction, value-grammar refusals included.
Published CI snippets must pin upstream Actions immutably, name an explicit reviewed crate version, and advertise the current release major. Version strings inside example fixtures are reproducible evidence, not claims about the latest release. None of this proves the meaning of free prose. It makes the mechanical drift visible, which is the part a machine can own.
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 twelve renderers call a heading, which is a separate study and not a
row on this page.
Two bases bend that convention: ripgrep tags rarely, so its base is the 150th ancestor
of its head, and alacritty tags on release branches, so its base is the latest stable
tag’s merge point with master.
| 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) was
already in flight with the identical one-character fix, which is independent confirmation
of the finding rather than a missed contribution. ripgrep and just were spotless on
missing references; just’s single advisory row sits on a nine-line change, the
small-denominator case that shows why the historical ratio is not a product rule.
The three rows with a named rejection class map the adoption boundary, and none of their
missing counts is a resolver bug. mdBook’s 36 all live inside its own link-handling test suite, deliberately
broken fixtures under tests/testsuite; its range changed no Markdown at all. starship’s
242 are extensionless clean URLs its site router resolves and the tree does not,
concentrated in translation mirrors of the preset pages. ruff’s 102 name targets its
docs build generates and the repository never holds, settings.md and rules.md
mostly, plus three literal template placeholders. Amiss reads every one of these
correctly against the tree; the maintainers would still close the report, and they would
be right to, which is what makes the class worth recording. These are the measured
adoption boundary that put declared generated targets on the roadmap, and the answer has
since shipped from the tracked ignore file, recorded in
Reference coverage.
The four later rows were picked deliberately from repositories without a docs-site
generator, and they produced no rejection class at all: every nonzero count there is a
real break. bat’s twelve are pre-existing and live in four translated READMEs whose
relative links carry the wrong prefix, doc/LICENSE-MIT for a root file and a doubled
doc/doc/ for siblings, and each renders as a 404 on GitHub today. alacritty’s one is
pre-existing in the recorded range: an earlier commit moved the escape-sequence docs
into the manpage and docs/features.md still links the deleted escape_support.md. fd
and hyperfine were spotless. On this evidence the rejection classes are a docs-site
phenomenon; a plain tree yields either zero or the genuinely broken.
The same ten trees, rescanned
Heading anchors resolve since What twelve renderers call a heading, so the ten trees were scanned again on 2026-07-26 with that work’s release build. These are not rows. Each is a whole-tree count at that day’s head against a synthetic empty base, so there is no commit range, no changed-line denominator, and no density figure. They are kept because the class mix that Reference coverage later answered moved.
| 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. Two columns of
this table read differently on the current build for that reason and one more: starship
reads 103 missing, and mdBook reads none at all, its whole count having been fixtures under
tests/, which discovery now skips by name.
146 name a target no spelling reaches, and 102 of those are ruff’s generated pages, 63 of
them into docs/settings.md. That is the class a docs build writes and a tree never holds.
123 are heading anchors no rule publishes, which the July build could not see because it resolved none. 122 are real breaks in five repositories: 103 in starship’s translated pages, where the heading was translated and the English fragment stayed, every one of them checked against the rendered page on starship.rs and absent there; 9 in helix from one reference definition whose section moved; 7 in bat from case and translation; 2 in ruff and 1 in fd from changelog entries that moved out of the file. The remaining one is deliberate, inside mdBook’s link-handling fixtures.
Those 516 have since become 238, and the arithmetic closes: 247 resolve as router spellings, 30 left with the fixture trees discovery now skips, and one was the defect below. What remains is 122 heading anchors and 116 targets no spelling reaches, with mdBook joining the three that were already clean.
The rescan also found one false missing, which is a defect and not a class: just’s README
titles itself with <h1 align=center><code>just</code></h1>, github.com anchors that, and
the rule table did not. It got a
pinned harvest and a fix, which is why just
reads zero above.
The Sphinx yield, measured
The v0.14 release taught the reStructuredText adapter the two Sphinx roles and the label
table behind :ref:. These are that work’s counts, taken 2026-07-31 on two Sphinx-native
trees with the main build carrying the two lexer fixes below, engine
sha256:5d1df7f8a4756f23aa7a78330ebc030438dc246f89cb06a7bb676062abc81c92. Like the rescan
above these are whole-tree counts at that day’s head, base one commit back, so there is no
range and no density figure. Django, the tree that motivated the adapter, was still
unmeasurable when these counts were taken: its reStructuredText lives in .txt, which the
built-in rows refuse. A policy include can now bind the rst adapter to exactly those
paths, stated in discovery, and the measured row follows below.
| Repository | Head | Documents | References | Labels | Resolved | Duplicate | Inventory | Missing |
|---|---|---|---|---|---|---|---|---|
| pytest | f306da747e70 | 305 | 1,408 | 460 | 449 | 0 | 6 | 5 |
| cpython | ac8ba0ca5a04 | 1,307 | 4,704 | 3,366 | 3,365 | 1 | 0 | 0 |
CPython’s Doc tree reads clean: every one of its 3,366 :ref: uses resolves through the
label table except a single genuine duplicate declaration. pytest’s five label misses split
two ways: package_env, twice in its changelog, is a tox setting no pytest label can
satisfy and renders unresolved in its own Sphinx build, a real break found live; the other
three are prefixless references an intersphinx inventory satisfies at build time, which no
tree-only scanner can tell from drift, the boundary
resolution states. The six inventory rows are the colon-prefixed form,
declared unsupported rather than missing. The remaining missing counts in each tree’s
summary, twenty and forty-two, are ordinary path references outside this study’s question.
The measurement earned its keep the way the just defect above did, twice over. The
v0.14 build’s first pass read 131 of pytest’s labels as missing, and triage proved the
docs innocent both times: pytest declares 180 labels in the backtick-quoted phrase form
the lexer kept quotes on, and CPython’s three “dead” labels were declarations sitting in
grid-table cells and a list item. Both false-missing classes got pinned fixes
(#203,
#205), which is why the table reads as it
does.
The Django yield, measured
The binding shipped and Django stopped being the counterexample. These counts were taken
2026-08-10 at that day’s default-branch head, on the main build carrying the three lexer
fixes below, engine
sha256:3a7263e876ec5ccd55f3b4899f8189af4567b8b21051bec255d93ffba0257a34. The method
bends one convention and states it: Django’s tree carries no Amiss policy, so the
candidate is a local commit whose only change is .amiss/scanner-policy.json, then holding
674 document includes that bind the rst adapter to every .txt under docs/, and the
base is the unmodified upstream head. The whole tree read in 1.1 seconds on an ordinary
development machine. The exact commit tree was checked again on 2026-08-22: it still holds
674 regular .txt blobs and 66 differently suffixed blobs under docs. The current policy
grammar names the same measured set with one {"path": "docs", "kind": "tree", "suffix": ".txt", "adapter": "rst"} selector without admitting those 66 other files.
| Repository | Head | Documents | References | Labels | Resolved | Duplicate | Inventory | Missing |
|---|---|---|---|---|---|---|---|---|
| django | c9eb16a87e60 | 674 | 3,008 | 1,462 | 1,430 | 0 | 10 | 22 |
Every one of the 22 label misses is a reference an intersphinx inventory satisfies at
build time, into Python’s own documentation (old-string-formatting,
context-managers, tut-packages and kin) or into Sphinx’s generated genindex and
modindex, the same tree-only boundary resolution states and pytest’s
row hit first. The ten inventory rows are the colon-prefixed intersphinx form, declared
unsupported rather than guessed. No label is declared twice.
The first pass read 43 rows as missing, and triage proved twelve of them innocent in
three classes, which became the lexer fixes this section’s build carries: an indirect
hyperlink target (.. _MySQL manual: MySQL_, and the embedded <Granian_> form) is an
alias to another target, not a path; the :file: role is presentation markup that makes
no link, distinct from the csv-table option the extractor exists for; and a figure
ending .* is Sphinx’s builder-resolved glob. Each shape now extracts nothing, pinned
one line at a time in the adapter’s tests.
What remains after the fixes is nine path references that are genuinely dead in the
tree: relative directory links like ../middleware/ and ../settings/ in five pages,
docs/topics/i18n/translation.txt holding four, left from the documentation structure
Django retired when it moved to Sphinx, plus ../url_dispatch/ in the sitemaps
reference. Each names a route the current tree does not hold under
the spellings a documentation router serves, which is the same
class as helix’s one-character break: real, pre-existing, and invisible to a build that
never resolves them.
The same ten trees, a third time
Scanned 2026-08-10 on the main build the Django row used, engine
sha256:3a7263e876ec5ccd55f3b4899f8189af4567b8b21051bec255d93ffba0257a34, each tree at
that day’s default-branch head against the same synthetic empty base as the second pass,
depth-one clones. The wall column is new: one process, one scan, measured around the
whole invocation on an ordinary development machine. These are the book’s first recorded
timings.
| Repository | Head | References | Missing | Anchor | Absent | Wall (ms) |
|---|---|---|---|---|---|---|
| helix | 079a789e8cb0 | 3,249 | 10 | 9 | 1 | 1,086 |
| ripgrep | 3fce3b5bb023 | 766 | 0 | 0 | 0 | 347 |
| just | 4f41f609278e | 3,234 | 0 | 0 | 0 | 1,111 |
| mdBook | b90df240a318 | 922 | 0 | 0 | 0 | 400 |
| starship | 545c0621a209 | 7,510 | 103 | 103 | 0 | 14,671 |
| ruff | 78cad66655dd | 5,383 | 10 | 2 | 8 | 1,795 |
| bat | 2ba8db9c14e5 | 399 | 19 | 7 | 12 | 132 |
| fd | ee20f426ddf3 | 96 | 1 | 1 | 0 | 64 |
| hyperfine | f12f3d9f86f3 | 48 | 0 | 0 | 0 | 24 |
| alacritty | 1b2b36a64e88 | 87 | 0 | 0 | 0 | 57 |
The second pass’s arithmetic predicted 122 real heading anchors, and the current build measures exactly 122, in the same five repositories at the same counts: 103 in starship’s translated pages, 9 in helix, 7 in bat, 2 in ruff, 1 in fd. The absent class fell from 116 to 21. The tracked-ignore answer emptied ruff’s generated-target class whole, alacritty’s one break was fixed upstream between passes, and mdBook stays clean at a newer head. Reference totals move with coverage and with the trees themselves: mdBook’s count drops because discovery now skips its fixture suites, bat’s because its documentation moved.
Every remaining absent row was read at its source line, and none is a resolver defect.
helix still carries the one-character ./themes.md break, its community fix
(helix-editor/helix#16034) open
since July. bat’s twelve are the translated-README 404s of the first study, unchanged.
ruff’s eight split three ways: five angle-bracket placeholders in an agent-skill
template, one literal teaching example inside a changelog entry, and two broken relative
links inside ty’s markdown-based test fixtures under resources/mdtest, a fixture
tree the deliberately closed skip list does not name. A maintainer would close all
eight, and the rejection classes of the first study still describe them.
The timings say what the architecture promises: a scan is one pass over two snapshots, so every plain tree answers in under two seconds, and Django’s 674 bound documents in the section above answered in 1.1. starship is the outlier its 7,510 references across twenty-two translation mirrors earn, and even that finishes inside fifteen seconds.
Remeasured 2026-08-11 after the anchor lane learned to reuse discovery’s own parse:
starship reads 5.7 seconds on the same tree, engine
sha256:3423bdfa922c64f9c6ebc7be0fb6242ac1afb5d977b0504f7c0c273f8ac44fd0. The removed cost was a second full parse of every
distinct anchor target; what remains is the parse-and-discovery baseline over the
mirrors’ 17 MB, a recorded fact with a weekly bench now watching the mirror shape.
Notebook Markdown yield, measured and held
The notebook question was measured on 2026-08-26 before admitting another document
format. Ten pinned trees supplied 3,878 exact lowercase .ipynb blobs and 461,907,733
bytes. Every tree with at most 120 notebooks supplied all of them; larger trees supplied
120 paths at evenly spaced indexes after lexicographic sorting, including the first and
last. That deterministic sample held 762 blobs and 104,270,321 raw bytes.
| Repository | Head | Notebooks | Empty | Over 4 MiB | Sample |
|---|---|---|---|---|---|
| openai/openai-cookbook | a7c8782de788 | 271 | 0 | 4 | 120 |
| microsoft/ML-For-Beginners | d0d0ea2b2d22 | 2,856 | 224 | 0 | 120 |
| jakevdp/PythonDataScienceHandbook | d66231454ef7 | 136 | 0 | 0 | 120 |
| fastai/fastbook | e8baa81d89f0 | 44 | 0 | 0 | 44 |
| tensorflow/docs | 35e0922e059d | 188 | 0 | 0 | 120 |
| pandas-dev/pandas | 668be9d6d677 | 1 | 0 | 0 | 1 |
| matplotlib/matplotlib | e519c449e932 | 3 | 0 | 0 | 3 |
| jupyter/notebook | 062a2e41d3d2 | 16 | 0 | 0 | 16 |
| anthropics/claude-cookbooks | 35f2eec7e448 | 98 | 0 | 1 | 98 |
| keras-team/keras-io | 7990430c3246 | 265 | 0 | 0 | 120 |
Fourteen sampled blobs were empty, all from the Microsoft tree. The other 748 were
nbformat 4.0 through 4.5 documents. Their Markdown source values were joined exactly as
the notebook format
requires and each cell was passed independently through the production Markdown
extractor. No code, output, attachment, kernel, or repository program was interpreted.
The extractor accepted all 15,245 cells.
| Repository | Valid sample | Markdown cells | With reference | With path-like | Path-like occurrences | All occurrences | Output share |
|---|---|---|---|---|---|---|---|
| openai/openai-cookbook | 120 | 2,041 | 111 | 52 | 211 | 1,006 | 40.4% |
| microsoft/ML-For-Beginners | 106 | 938 | 104 | 20 | 68 | 765 | 90.4% |
| jakevdp/PythonDataScienceHandbook | 120 | 3,000 | 116 | 110 | 926 | 1,711 | 93.0% |
| fastai/fastbook | 44 | 2,429 | 26 | 17 | 137 | 342 | 82.5% |
| tensorflow/docs | 120 | 3,366 | 120 | 58 | 272 | 2,366 | 7.8% |
| pandas-dev/pandas | 1 | 86 | 1 | 1 | 53 | 73 | 0.0% |
| matplotlib/matplotlib | 3 | 25 | 0 | 0 | 0 | 0 | 81.8% |
| jupyter/notebook | 16 | 149 | 7 | 3 | 11 | 49 | 4.6% |
| anthropics/claude-cookbooks | 98 | 1,202 | 66 | 32 | 109 | 502 | 73.4% |
| keras-team/keras-io | 120 | 2,009 | 119 | 13 | 38 | 1,100 | 5.1% |
Path-like is deliberately the lower-bound lexical class: no URI scheme, no leading
fragment, no attachment: scheme, and no network-path // prefix. It does not count a
same-repository forge URL which the resolver could answer. Even under that restriction,
306 of 748 valid notebooks held 1,825 candidates for the existing repository resolver.
The full extraction held 7,914 occurrences: 5,836 scheme-bearing or network-path
destinations, 231 cell-local fragments, and 22 attachments in addition to those 1,825.
The format has real coverage yield rather than merely category adjacency.
The cell bodies are small. The file row covers all 3,878 tree entries; the other two rows
cover the sample. Each percentile selects the sorted value at zero-based index
round((n - 1) * p / 100), with half-way ranks rounded to even.
| Value | p50 | p95 | p99 | Maximum |
|---|---|---|---|---|
| notebook bytes | 26,351 | 494,513 | 678,502 | 11,132,496 |
| Markdown-cell bytes | 221 | 1,504 | 2,785 | 38,276 |
| Markdown-cell lines | 3 | 19 | 41 | 569 |
Only five tree entries exceed the current 4 MiB document ceiling, and none exceeds 16
MiB. Size is not the reason to refuse the format. Materializing the notebook is: code-cell
outputs alone occupied 69,870,711 of the sample’s 100,218,023 whitespace-free JSON bytes,
69.7 percent, before counting execution state or notebook metadata. Outputs were present
in 342 notebooks, exceeded half the bytes in 204, and exceeded 90 percent in 106. Five
more notebooks carried 15,230,286 bytes of Markdown attachments. A future reader must
skip those values while decoding rather than build an owned notebook tree or feed them
to the Markdown parser.
Location is the unsatisfied gate. Of 15,245 Markdown cells, 15,189 used an array source and 56 one string; 104 of 78,157 array members themselves held more than one source line. A decoded Markdown span can therefore cross JSON strings, quotes, commas, and escapes. It is not one physical notebook byte span. Cell IDs do not rescue the current contract: only 2,429 of 27,601 cells had one, and the sample contained one duplicate. An index plus an optional valid ID and a cell-local span can name the source honestly, but the current report location has only repository path and physical document span.
The provider UIs cannot consume a substitute. GitHub Check annotations require a path and physical start/end lines, while its rich notebook diff still requires switching to the raw source diff to comment on a line (Checks API, notebook diff limitation). GitLab transforms notebook diffs on commit and compare pages, explicitly not on merge request pages, and offers no code suggestions for notebooks (GitLab notebook diffs). A raw-JSON annotation would be clickable but would name the serialization rather than the Markdown source the finding describes.
Notebook parsing is therefore held, not rejected. Shipping waits for a cell-aware report location and at least one provider or design partner that can use it. That change must be reviewed as a wire migration before the parser: cell source stays isolated, output and metadata stay skipped, empty or malformed notebooks fail visibly, and a cell-local finding is never disguised as a contiguous raw-JSON span.
MyST, Quarto, and Org yield, measured and held
The three adjacent markup formats were measured on 2026-08-27 before admitting another
parser. Thirteen pinned trees supplied every exact lowercase source suffix outside the
nine built-in excluded directory names: .md in four MyST-family trees, .qmd in four
Quarto trees, and .org in five Org trees. There was no sampling. The resulting 1,559
documents held 22,015,633 bytes.
For MyST and Quarto, every document was first passed unchanged through the production
Markdown extractor. A second, source-positioned pass counted only the documented dialect
forms outside ordinary code fences and escaped examples: MyST directives and roles, and
exact Quarto shortcodes and cross-references. Triple-brace Quarto examples are escapes, not
shortcodes. Org bracket
links and keywords were read with lossless orgize 0.10.0-alpha.10 and cross-checked
against Pandoc 3.1.11.1. No code cell, Babel block, plugin, shortcode, included file, or
repository program was executed. A repository candidate was then resolved under the
format’s documented relative-path rule against the pinned Git tree; generated output and
renderer context were never guessed into existence.
MyST
| Repository | Head | Documents | Markdown references | Markdown path-like | Dialect repo targets | Existing | Internal roles |
|---|---|---|---|---|---|---|---|
| jupyter-book/mystmd | d41a821e2244 | 312 | 1,343 | 255 | 129 | 126 | 728 |
| jupyter-book/jupyter-book | fc05697264cc | 42 | 857 | 39 | 5 | 5 | 2 |
| canonical/lxd | 44a4c0ded139 | 235 | 966 | 348 | 254 | 253 | 1,344 |
| pyOpenSci/python-package-guide | 10277117d7bd | 53 | 1,118 | 253 | 35 | 35 | 0 |
These files are already discovered as Markdown, and the production adapter accepted all
642. It extracted 4,284 ordinary references, including 895 path-like repository
candidates. That is genuine existing coverage, but not MyST coverage. The dialect pass
found another 423 repository candidates in {include}, {literalinclude}, {image} and
{figure} directives and {doc} and {download} roles; 419 name an entry under the
documented source-relative spelling. It also found 2,074 internal role uses, predominantly
2,069 {ref}, {numref}, and {eq} uses whose label and inventory semantics CommonMark
does not have. Twenty-one dialect file targets are external.
The four file targets a plain source-relative lookup did not find are the useful boundary,
not four defects. Three are deliberate my-file teaching values in MyST’s own reference
guide. LXD’s root CONTRIBUTING.md supplies the fourth: doc/contributing.md includes it,
and its {doc} role reaches doc/debugging.md in the including Sphinx document’s context.
The MyST include contract makes the include path
relative to its source, while roles and extension points still require the parsed document
context. A suffix alias cannot reproduce that distinction.
Quarto
| Repository | Head | Documents | Markdown references | Markdown path-like | Includes | Cross-references | Executable cells |
|---|---|---|---|---|---|---|---|
| quarto-dev/quarto-web | db4c9fc6a00e | 578 | 3,973 | 1,974 | 376 | 61 | 322 |
| ropensci-books/targets | 1d14652363c1 | 20 | 643 | 11 | 0 | 2 | 261 |
| r-universe-org/docs | f5e288ed1e7e | 25 | 199 | 31 | 0 | 0 | 15 |
| nasa/ECOSTRESS-Data-Resources | d9155bc369ac | 3 | 14 | 1 | 0 | 0 | 0 |
All 626 .qmd documents were valid input to the production Markdown adapter, which found
4,829 references and 2,017 path-like repository candidates. Amiss currently sees none of
them because .qmd is outside the document set. The Quarto-only pass found 376 exact
include shortcode targets, every one present in the pinned tree, and 63 cross-reference
uses. Initial YAML metadata held 35 file-bearing fields which expanded to 22 concrete
bibliography, resource, or body-include values.
That large safe-looking Markdown subset still does not justify binding .qmd to the
Markdown adapter. Quarto’s include contract
preprocesses an include even inside a code fence and resolves references in the inserted
text from the main document rather than from the included file. The same corpus held 598
executable or diagram cells and 452 other renderer shortcodes, including 18 notebook
embeds. Cross-reference declarations can also be produced by executed cells. Those are
renderer evidence, not repository syntax the engine may simulate.
Org
| Repository | Head | Documents | Bracket links | Relative files | Internal | Include/setup | Existing | Plain-tree miss | Source blocks |
|---|---|---|---|---|---|---|---|---|---|
| bzg/org-mode | 76e4dbe07f93 | 45 | 486 | 4 | 370 | 7 | 11 | 0 | 274 |
| org-roam/org-roam | 903bd4ec56d2 | 1 | 60 | 3 | 10 | 0 | 3 | 0 | 59 |
| tecosaur/orgmode.org | 770916ecb648 | 16 | 212 | 54 | 0 | 32 | 64 | 22 | 25 |
| SystemCrafters/systemcrafters.github.io | 6ccb1aa279f7 | 186 | 942 | 192 | 0 | 0 | 82 | 110 | 752 |
| caiorss/C-Cpp-Notes | 98ccfc4b0858 | 43 | 7,870 | 271 | 0 | 39 | 267 | 43 | 4,284 |
The lossless pass found 9,570 double-bracket links: 524 relative file links, 380 internal
targets, 8,644 external schemes, one absolute file, and 21 custom or fuzzy shapes. Sixty-one
INCLUDE and seventeen SETUPFILE keywords raise the explicit repository-candidate total
to 602. Of those, 427 reach an entry by the plain source-relative rule and 175 do not.
Pandoc produced 12,628 link or image nodes because its Org reader also recognizes plain
external URLs; those add no same-repository coverage and were not substituted for the
lossless source counts.
The misses split along renderer boundaries. orgmode.org links generated manual, guide,
PDF, and HTML outputs absent from the source tree. System Crafters’ committed
live-streams.org is a generated sitemap whose sibling-looking links resolve from the
content/live-streams publishing project, not from the file’s physical content parent.
The C++ notes repeatedly include an absent theme/style.org, while two encoded file names
need Org’s decoding rule. The Org file-link contract,
include grammar, and
HTML export rewrite are separate
semantics; recognizing brackets alone would turn build context into false missing rows.
One Org document, Rosetta_Stone_Translation.org, is 7,154,544 bytes and exceeds the
default 4 MiB document ceiling. It carries 2,189 bracket links, three repository
candidates, and thirteen source blocks. The other 290 Org documents fit the current byte
limit. The size crossing remains a visible per-document refusal and is not a reason to
raise the built-in per-document ceiling for a new parser.
Admission decision
The measurement establishes real yield but admits none of the three formats yet. MyST needs a distinct, policy-bindable grammar for roles, directives, labels, and transclusion context; the production dependency graph has no pure-Rust MyST parser pinned to that contract. Quarto needs a dedicated adapter which preserves the measured Markdown subset while representing includes and refusing execution- or plugin-derived targets; calling it Markdown would overstate coverage. Org needs a bounded parser pinned against the GNU Org renderer plus explicit publishing-context evidence; the pure-Rust parser used for measurement is still an alpha, not the production contract.
Unlike notebooks, all three formats can identify their authored constructs with physical byte spans in the source file. None needs a new location shape or wire v2; an admitted adapter would still move the normal additive schema and examples. What is missing is renderer conformance and, for Quarto and Org publishing, trustworthy build context. Shipping waits for that evidence and a provider or design partner that needs the format; executable outputs and plugins remain external evidence in every design.
Editor latency and reverse-impact demand, measured and held
The reverse-impact query had no user population to measure on 2026-08-27. It merged in
PR #526 on August 24, after
v0.25.0, the latest release, was
cut on August 21. Authenticated GitHub code search for the exact amiss refs phrase returned
six matches, all in this repository, and the issue register held no LSP request. Aggregate clone
traffic cannot distinguish a command invocation. A source build may have used the query, but
there is no observable evidence that an author has, much less that its latency blocks an editor
workflow.
The performance half was measured independently rather than inferred from that absence. The
release-mode binary at b70ec4164269, engine
sha256:c8c667d59c8b960472815999ba45b1fbafcdfaa1adf017c0f238d722a696b1d3, ran on Linux
6.12 on an AMD Ryzen 7 PRO 7840U. Each sample was a new process against a warm page cache,
with stdin closed and output written to the null device. One unrecorded warm-up preceded each
series. Wall time surrounded process creation and wait4; CPU time and peak resident memory
came from that same child. The table uses the median and nearest-rank p95.
Three fully hydrated depth-one SHA-1 clones span the existing workload. Each staged index
equaled its base. Django’s local base additionally held the same exact
{"path":"docs","kind":"tree","suffix":".txt","adapter":"rst"} policy binding as
the earlier yield study, so both sides scanned the adopted document set. Every warm-up produced
a complete observe report with no analysis error.
| Repository | Upstream head | Scanned documents | References | Report bytes |
|---|---|---|---|---|
| sharkdp/hyperfine | f12f3d9f86f3 | 3 | 48 | 173,805 |
| django/django | 7576781cc671 | 683 | 3,028 | 16,569,453 |
| starship/starship | cb5415985ca1 | 449 | 8,608 | 37,472,463 |
Startup is amiss --help. A refs row reopens the corresponding complete report and
queries its most frequent repository target. A scan row runs the complete staged evaluation
and serialization under --profile observe --format json.
| Surface | Samples | Wall p50 | Wall p95 | CPU p50 | RSS p50 |
|---|---|---|---|---|---|
| startup | 100 | 2.46 ms | 2.88 ms | 2.21 ms | 13.8 MiB |
hyperfine refs | 30 | 7.40 ms | 8.06 ms | 7.06 ms | 13.8 MiB |
| hyperfine scan | 20 | 45.26 ms | 48.27 ms | 44.40 ms | 13.8 MiB |
Django refs | 15 | 396.58 ms | 608.42 ms | 395.90 ms | 69.7 MiB |
| Django scan | 12 | 800.73 ms | 865.33 ms | 794.81 ms | 68.0 MiB |
starship refs | 10 | 579.13 ms | 644.62 ms | 571.47 ms | 154.1 MiB |
| starship scan | 7 | 4,677.57 ms | 5,551.65 ms | 4,682.70 ms | 129.7 MiB |
Process startup is not the bottleneck: even the smallest complete scan costs eighteen times its fresh-process floor, while starship costs about nineteen hundred times it. A resident daemon would save roughly 2.5 ms and leave the material work untouched. Report replay stays subsecond here, but its owned JSON tree makes the 35.7 MiB starship report peak above the full scan. That mirror workload clearly exceeds interactive latency and needs a proved selective or incremental evaluation, not merely a process kept alive.
No editor mode ships from this evidence. Reopen the question only after a release containing
refs has an external user or design partner who can name the interaction and repository where
latency hurts. Start by measuring a stateless explicit invocation there. Any later worktree or
buffer overlay remains observe-only convenience output, never provider-gating evidence; hidden
incremental state, a long-lived engine, and background network access remain out until their own
correctness and resource model is proved.
Provider service packaging demand audited and held
No operator request justified a deployment package on 2026-08-28. Authenticated GitHub issue and
pull-request searches for docker, container image, OCI image, Helm, Kubernetes,
self-hosted, provider service, systemd, and deployment platform returned no result in this
repository. Exact public code searches for each of amiss-controller-github,
amiss-controller-gitlab, and amiss-controller-gitea found only this source tree. The one
observable live lane repository is the maintainer-owned
amiss-lane-github, whose stated purpose is to
exercise a real GitHub App against a deliberate test scenario. It proves that a source-built lane
has run; it does not establish demand for an image or an operator’s packaging contract.
The latest release at the time of the audit was
v0.25.0. Its platform assets contain
the scanner and prober, checksums, and provenance, but no provider service. That is consistent with
the supported contract rather than a missing release step: the three service crates are
unpublished, and their setup pages direct an operator to build the chosen repository commit with
--locked. One commit therefore selects the controller, provider adapter, engine wire,
constraint producer, and sealed bootstrap together.
The process boundary is already suitable for an operator-selected supervisor. Each service loads one absolute strict-JSON configuration, validates it offline before startup, binds plain HTTP to a private address behind an operator TLS proxy, exposes private health, readiness, and fixed metrics routes, and drains admitted work on termination. Configuration names pre-created, disjoint scratch, ledger, artifact, and, for webhook lanes, inbox roots. Credentials, webhook keys, execution constraints, bootstrap binaries, and optional controls arrive through bounded regular files. These are the inputs an image would have to mount and the state a rollout would have to preserve.
There is consequently no neutral container definition to add. Choosing a base and target architecture would not answer the runtime user and filesystem ownership, secret injection, TLS edge, private-route exposure, persistent-volume backup, or configuration migration rules. A Helm chart or generated configuration would make even more assumptions, while a separately versioned provider package would break the single-commit closure before 1.0.
No OCI image, service-manager unit, configuration generator, or control plane ships from this audit. Reopen packaging when an operator names the deployment platform, image-signing verification flow, upgrade and rollback contract, and isolation model for secrets, routes, and durable roots. The first package must preserve the same-commit service/bootstrap/engine closure and reuse the release’s existing platform provenance where that operator can verify it; it must not publish the controller crates as a second library product.
What a row must be
A row enters this page only from a recorded run: the machine report kept, the commit pair stated, every raw value sourced from and every derived or classified column traceable to those two artifacts, on a repository that is not this one. The validation phase used the ledger to retain the ten-repository adoption and false-missing evidence; focused PR feedback is now a separately tested product invariant.
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. Both families blocked the drift, refused the merge, and approved the correction.
In July GitLab had no row: the lane’s floor is 19.3 with Ultimate, 19.2.0 was the newest release, and a live 19.2.0-ee confirmed only that the floor refuses the instance.
August 2026
Controller d6d42de, action tree pinned at commit
b4b576da872c2e5b7243264919a324e39b7276ad. The instance is gitlab.com itself, running
19.3.0-pre (revision 6ec80fea941) ahead of the self-managed 19.3 release, under an
Ultimate trial namespace. The candidate content is held fixed across the pair and only
the enforcement control moves.
| Provider | Version | Control | Provider evidence | Gate commit |
|---|---|---|---|---|
| GitLab | gitlab.com 19.3.0-pre | train enforced for all users | policy job success, train merged | 73da3cdf0319 |
| GitLab | gitlab.com 19.3.0-pre | merge_train_enforcement: allow_bypass | policy job failed on 412, train dropped the car | 270b65505c42 |
The revocation was restored afterwards and the restored train merged the same content.
Drift verdicts from the same instance, where the candidate broke a documented reference
and the control stayed intact, are recorded in
pull request 332: the drift refused, the
completed move approved.
Running the lane live found two defects every fixture had agreed with, keeping the July
pattern: the documented policy job wrapped its script across YAML lines that policy
injection preserves literally, so the book now keeps the command on one physical line,
and gitlab.com answers the jobs API with a null source for the policy job, so the
adapter now accepts an absent REST source while the signed OIDC job_source claim and
the pinned job_config binding continue to state the provenance.
What a row must be
A row enters this page only from a verdict the provider published and still holds: the provider version as the provider reports it, the controller commit that produced the run, the gate commit the verdict names, and the provider’s own record of the verdict. A positive row and a revoked-control row must differ in the control alone. A run against a local HTTP fixture never becomes a row, however faithful the fixture looks.
Roadmap
This page tracks the work ahead: what is being done now, and what stays research. It is
not release notes or a promise that anything listed here will ship. The wire contract
froze at 1 in August 2026 and left this page; the record is in
A settled wire, and the frozen regime’s law lives with
The report. Coverage that has
landed is described where it works rather than here. The factual boundary of the current
product is in
Project status, the exit evidence for phases already closed is in
Completed phases, and version history is in the
changelog.
Research, not committed work
Value claims shipped as the first evaluated kind: Claims states the closed grammar, and everything outside it keeps the unsupported-capability boundary. Typed snippet, inventory, tree, graph, transcript, narrative, and external claims remain research. Persistent acceptance records and governed review state reopen the storage, concurrency, ownership, expiry, and cheapest-bypass problems the stateless scanner avoids, the same problems that killed the ledger design in Provenance.
No claim kind becomes a milestone without design-partner demand, a proof-strength model, evidence that reviewers find it useful, and experiments covering persistence and concurrent branches. Until then these are design vocabulary, not advertised capability. Demand has a place to land: open an issue on the repository naming the claim kind and the repository it would gate, with one drifted example that reference checking cannot catch. The claim-demand issue form asks for those three, and optionally what the claim should have pinned. That register is what this section reads before anything here becomes work.
The permanent boundaries stay in What Amiss is not: no semantic truth verdicts about prose, no repository-executed hooks, no live-network validation inside the engine, no automatic prose rewriting, and no repository-controlled weakening of a required policy.
Renderer-aware adjacent formats
The measured MyST, Quarto, and Org yield establishes useful repository-reference coverage, but does not make any of these formats committed work. They remain three separate future tracks:
- A MyST adapter would be explicitly policy-bound because
.mdalready means CommonMark/GFM. It must model directives, roles, labels, and transclusion context against a pinned renderer contract rather than reinterpret every Markdown document. - A Quarto adapter would preserve the ordinary Markdown subset while modeling Quarto’s
main-document include context and static cross-references. Executed cells, notebook embeds,
plugins, and renderer-produced declarations remain external evidence;
.qmdis not a suffix alias for Markdown. - An Org adapter would need lossless physical spans and bounded file-link, include, setup, and search syntax. Publishing roots and generated routes must arrive as sealed renderer evidence; the engine never evaluates Emacs Lisp or Babel, and oversized documents remain visible refusals rather than raising the global ceiling.
Admission still requires design-partner or provider demand and pinned conformance against the official renderer. All three can use the existing physical byte-span location shape, so none is reason to reopen the wire solely to name a source construct.
Editor feedback remains held
The editor-latency measurement found that the reverse-impact query had not yet shipped, with no observable external use and no editor-integration request. Fresh-process startup is about 2.5 ms; complete scans range from 45 ms on Hyperfine to 4.7 seconds on starship’s translation mirrors. Keeping the engine resident would remove the negligible part and retain the expensive one.
An LSP, worktree overlay, or incremental daemon is therefore research, not committed work. Reopen
it after a released refs command has a user who can identify a concrete author interaction and
repository where latency blocks them. Measure a stateless invocation there first. Any convenience
result remains observe-only and cannot satisfy a provider gate; persistent incremental state and
background network access need separate proof before they enter the design.
Provider packaging remains demand-gated
The packaging-demand audit found no operator request and no public use of a provider service outside this source tree. The maintainer-owned live GitHub lane proves the source-built service path, not demand for an OCI image, service-manager unit, configuration generator, or control plane.
Packaging remains research until an operator supplies the deployment platform, signing and verification flow, upgrade and rollback expectations, and the isolation model for secrets, routes, and durable state. Any eventual artifact must keep the service, bootstrap, wire, and engine on one chosen commit before 1.0. The unpublished controller crates do not become a separately versioned product to make packaging easier.
Completed phases
Eleven phases are closed, one page each. A page is a dated exit record rather than live documentation: it states what was true when the phase closed, what each claim defends against, and links the code that has to stay true for the claim to hold. Where a fact has moved on since, the page says so and points at the live chapter that owns it.
Current work is in the Roadmap, the factual boundary of the product is in Project status, and version history is in the changelog.
Validation and hardening asked whether the engine’s claims survive contact with repositories nobody here wrote. Generated contract tables, a bound on embedded code, ten public repositories scanned and kept, no false-positive rate, one reviewer projection, the event shapes the self-scan actually runs, and the first mutation and fuzz baselines.
Delivery record made a controller that publishes provider verdicts survive crashes, retries, and clock movement without losing a verdict or writing two. One atomic claim, a fenced lease shared with the runner, an authenticated replay lifetime, and a durable record built from ordinary files with fixed lock growth.
Provider-verified controls turned the gate into an object the provider owns and the checked repository cannot forge. One evaluation contract, a sealed bootstrap and runner, exact object acquisition, and three provider lanes with their gates checked rather than assumed.
Provider operations made a lane deployable, watchable, and restartable without losing work. Offline configuration checks, separate liveness and readiness, ten label-free counters, a graceful drain, and account-free robustness testing.
Reference coverage answered the four classes the scan ledger had measured and named, and refused a fifth. Heading anchors under twelve renderer rules, router spellings, generated targets read from the repository’s own declarations, AsciiDoc and reStructuredText, and the measurement that killed bare-path inference.
Live provider evidence replaced fixture belief with provider verdicts on every lane. A positive and a revoked-control pair per lane, candidate content fixed so one control moves per flip, github.com and gitlab.com and self-hosted Gitea and Forgejo, and six live-found defects that every fixture had agreed with.
A settled wire froze the report contract at 1 once its three
conditions held at once. The wire versioned by its own in-payload field, engine releases decoupled,
two minor series quiet under a mechanical tripwire, and the first frozen example retained
permanently to hold every later schema in the major to the additive promise.
Projection contracts made exact code, inventory, count, and record-set relationships policy-owned scanner facts. Stable identities, complete-input semantics, bounded difference previews, independent resource meters, and local authoring that never promotes self-asserted data into provider authority.
Authoritative semantic artifacts carried external producer bytes across the provider trust boundary and beyond provider retention. An immutable workflow-artifact plan, exact GitHub acquisition, restart-safe audit artifacts, and one isolated Rustdoc normalizer whose deliberately narrow completeness claim is attached through the generic projection contract.
Offline audit sidecars separated publication and locale facts from the scanner report without weakening either. Closed plan, evidence, and assessment contracts, exact report and product bindings, durable publication replay, page coverage, fallback provenance, and source lineage—with live deployment and locale intake still stated as operator-gated work.
Cross-repository relation core proved that one operator-owned relation can bind four exact snapshots, survive supersession and restart, and stage idempotent provider outcomes without letting either repository select the other. The provider-neutral core and provider boundaries closed; installation in the live provider binaries did not.
Validation and hardening
Closed July 2026. The engine was already written; this phase asked whether its claims survive contact with repositories nobody here wrote.
The book’s contract tables are generated, not written
Before this closed, the book mixed four maturity levels on the same page: shipped scanner behavior, the convenience Action, controller components that existed but were not wired to anything, and research ideas. Several mechanical claims had also drifted from the constants they described, which is the exact failure this repository sells a tool to catch.
Dispositions, resource ceilings, finding meanings, the refusal grammar, and the worked examples each exist twice: once in the engine, once on a page. The pages therefore do not keep their own copy. A contract test regenerates each table from the source of truth and compares:
documented_profiles_are_generated_from_the_policy_contractrebuilds the disposition table in Profiles and findings from the policy contract.documented_limits_are_generated_from_runtime_constantsrebuilds every row of the ceiling table in Limits and refusals from the runtime constants.documented_finding_meanings_are_generated_from_the_engine_textand its error twin compare the meaning sentences on the page against the engine’s own strings.documented_grammar_matches_the_refusal_grammarcompares the usage block in Invocation against the grammar the binary prints when it refuses.documented_finding_examples_cover_the_report_schemaandall_public_contract_examples_clear_their_schema_and_registered_readerrun every published example through the schema and the reader that ships, so an example cannot be aspirational.the_llms_index_names_real_chapters_on_the_published_bookresolves every row of the agent index to a chapter file, so the index cannot advertise a page that was renamed away.
The generators are ordinary functions in the same file, so a failure shows the expected table next to the one on the page rather than an assertion that something differs.
A claim that can be generated is generated. A claim that cannot links the code that implements it, which is why so much of the book is link-dense: the link is the check.
Aligned in #46, which also split the factual
Project status from the forward-looking Roadmap. Published
examples became executable in #60, semantic vectors
were enforced in #62, and the markers were made to
say what they do in #155. All of it lives in
crates/amiss/tests/documentation_contracts/.
Embedded code cannot buy unbounded parse time
The pinned MDX lexer answers one question at every candidate closing brace: can the embedded code end here. Each ask rescans the whole accumulated region. A region that never closes therefore costs time quadratic in its length, and a document full of unterminated regions is a cheap way to make a scanner spend an afternoon on a file nobody will read. The corpus notes recorded the case and left the bound to the resource ceilings, which is a polite way of saying the hole was known and open.
The fix charges the cost where it is spent. A resource,
aggregate-embedded-code-evaluation-bytes-per-snapshot, joins the wire enum, both schemas, the
floor-tightening map, and the generated limits table:
#![allow(unused)]
fn main() {
aggregate_embedded_code_evaluation_bytes_per_snapshot: 536_870_912,
}
The parse hooks charge every ask against the snapshot’s remaining allowance before the lexical
scan reads it. Crossing the ceiling aborts the parse and surfaces as an ordinary
RESOURCE_LIMIT_EXCEEDED row carrying the resource triple. It never becomes a claim about the
document, because the scanner did not finish reading the document and saying anything about it
would be a guess. Spend accumulates across documents rather than resetting per file, so the
ceiling is a snapshot budget: a thousand small hostile documents cost the same as one large one.
The 64Ki-brace hostile fixture that used to be quadratic now finishes in under 30 milliseconds.
Outside the engine, the convenience Action gained a wall-clock watchdog on the scan step, 120
seconds by default and movable through watchdog-seconds. It is written in plain bash rather than
timeout from coreutils, because coreutils is not the same program on all four runner platforms
and a watchdog that behaves differently per platform is worse than none. When it fires the engine
is terminated, the step says so, and the job fails with no report, which is the correct outcome:
no result is not the same as a pass.
Bounded in #81. The meter is
crates/amiss-md/src/accounting.rs,
the value is in
crates/amiss-scan/src/resources.rs,
and the watchdog input is in
action.yml.
Ten public repositories were scanned and the counts kept
A tool that reports missing references can only be evaluated against repositories it did not grow up with. The first six such scans lived in a session scratchpad, one crash away from gone, which made the adoption argument a memory rather than evidence.
They became a book page instead, The scan ledger, and then grew to ten. One row is
one scan: a public repository, a base and candidate commit pair, the observe profile, a release
build. Each row records the commit range, references extracted, missing count, advisory rows,
changed documentation lines, the historical density per hundred changed lines, and the class of any
finding a maintainer would reject. Every raw value comes from the kept machine report or from
git diff --numstat over the same commit pair. Derived columns are derived from those two
artifacts and never remembered.
The result splits three ways. Four repositories came back spotless. Three carried only real
breaks: one introduced in helix, twelve pre-existing in bat across four translated READMEs whose
relative links carry the wrong prefix, and one pre-existing in alacritty where the escape-sequence
docs moved into the manpage and docs/features.md still links the deleted page. The remaining
three mapped systematic non-adoption classes rather than defects.
The page also fixed its own column definitions and a small-denominator rule before more rows accumulated, because a density figure over nine changed lines is noise and would otherwise be quoted as a finding.
Every class the study named has since been answered in the engine rather than left as a caveat, which is Reference coverage. The ledger carries the rescans that measured each one.
Committed in #82 with six rows, grown to ten in #86.
A false missing target is a bug, not a statistic
A checker that reports references which actually resolve teaches maintainers to ignore it, and a muted check is worse than no check because it also consumed the attention it was meant to protect. The usual industry answer is a false-positive rate. This project does not have one.
A false explicit-target-missing on a supported reference is a resolver defect. It gets a pinned
test and the accepted count is zero. That distinction is what makes The scan ledger
readable: a nonzero missing count in a row is either a real break or a named class, never a
tolerated error margin, so nobody has to guess which.
Holding that line costs a large test surface, because every supported reference shape needs a case.
crates/amiss-scan/tests/resolve/
runs to around 1,450 lines for that reason, covering component splitting in RFC order,
line-selection bounds as structural outcomes, LFS pointer targets, exact target digests,
directories resolved identically through a commit and through the index, paths compared as bytes
with no case folding and no normalization, and the GitHub, GitLab, and Gitea URL dialects each
resolved against the tree rather than pattern-matched.
The same rule is why a GitHub URL needs the whole trusted chain before it resolves, pinned by
github_urls_need_the_whole_trusted_chain: guessing that a URL belongs to this repository, and
reporting it missing when the guess is wrong, would be the same defect wearing a different hat.
Review feedback is grouped, ordered, and bounded
Before this, consumers reshaped raw findings themselves. The command line did it one way, the Action another. That leaked the engine’s internal taxonomy into review, duplicated the classification logic in two places that could disagree, and let harmless inventory rows crowd out the two lines a reviewer needed to read.
The engine now owns one deterministic reviewer projection. feedback groups review work by the
target it concerns and classifies each item as Fix, Check, or Existing. Classification derives from
correlation, attribution, and location metadata rather than from a match over FindingKind, so
adding a finding kind does not mean editing the reviewer’s view to keep it sensible.
The ordering and the caps are the part that matters in practice. Fixes come before Checks, so what must change is read first. Existing findings never take a pull-request annotation, because annotating code the author did not touch is precisely how a check earns a mute. Scan errors stay separate from findings, since “the run did not complete” is a different statement from “this reference is broken”. The human and Action views cap at ten combined items, and only candidate-located displayed Fixes become annotations. Nothing is dropped: every exact finding stays in the JSON report, which is the artifact for tooling, while the reviewer view is for people.
An incomplete run reports feedback as explicitly unavailable rather than as an empty list, because an empty list reads as “nothing to do” and that would be a lie about a run that failed.
Shipped in #95, which also made removed references
recorded facts, and rendered in
crates/amiss-scan/src/feedback.rs.
The annotation boundary is the annotations input in
action.yml; annotation flooding was
addressed in #68.
The self-scan runs the event shapes it claims to support
This repository gates itself with its own action under enforce, which is only evidence if the
self-scan exercises the event shapes real users hit. A gate that has only ever seen an ordinary
push proves nothing about a shallow checkout, and the failure mode there is not a crash: a scan
with a truncated history silently compares against the wrong base.
Recorded runs cover push, same-repository pull request, depth-two shallow checkout, and the
staged-index path, the last of which runs --base "$(git rev-parse HEAD^)" --index against the
checkout’s clean index on every CI run. Two corrections were needed to make those rows mean
anything. The self-scan first had to fetch full history, since a shallow clone gave it a base it
could not resolve. Then the pull-request base had to be derived from the merge commit itself rather
than from the event payload, because the payload’s base is where the branch started, not what the
merge would actually compare against.
The fork path deliberately uses the same unprivileged pull-request workflow rather than a second privileged one, so there is no second code path that only forks take and only forks can break.
Fork and merge-group runs are not retained as phase gates, and the reason is recorded rather than
hidden: as of July 2026 GitHub offers no merge queue to this public, user-owned repository. The
merge_group trigger and its event mapping stay in place so a repository that does have a queue is
covered. That is a claim about readiness, not about testing, and the distinction is the point.
The job is self-scan in
.github/workflows/ci.yml.
History depth fixed in #70, the shallow and
staged-index rows recorded in #85, and the base
derived from the merge commit in #88.
Mutation and fuzz runs are installed with recorded baselines
A suite that only ever runs green says little about whether it would catch a regression. Two non-gating runs answer that question separately from the gates: a mutation run and a nightly coverage-guided fuzz run.
The first mutation run recorded 2,728 mutants with 664 missed on 2026-07-18, and it paid for itself immediately by showing the release-manifest laws untested, which 323 lines of tests then covered. That is the intended use of a mutation run: not a score, a list of places where a lie would go unnoticed.
Both runs are deliberately non-gating. A weekly signal converted into a merge gate becomes a flaky merge gate, and a fuzz run that must finish before a merge is a fuzz run that stops looking hard.
The baseline has moved since, and the current reading lives with the tooling that produces it in Development rather than here: the sweep of 2026-07-28 measured 6,523 mutants over both workspaces. The lanes were rebuilt around that scale, so a pull request now measures only the mutants its own diff reaches.
Installed and excluded from the fixtures crate in
#83; the untested manifest laws the first run exposed
were covered in #87. The schedules are
.github/workflows/mutants.yml
and
.github/workflows/fuzz-long.yml.
Delivery record
Closed July 2026. A controller that publishes provider verdicts has to survive crashes, retries, and clock movement without losing a verdict or writing two. The lifecycle arrived in #98, #100, and #103, was finished in #105, and was bounded in #118.
Claim, lease, result, and completion are one contract
The controller before this could preserve an evaluation ID across retries and little else. It had no way to represent two workers owning the same delivery, and no way to represent the window between finishing an evaluation and publishing it, which is exactly where a crash costs you either a lost verdict or a second one. A stale worker could not be rejected, because there was nothing to reject it with, and a retry had no immutable result to resume from.
DeliveryLedger replaced that with one atomic claim whose answer is the whole coordination
contract:
#![allow(unused)]
fn main() {
pub enum DeliveryClaim {
Execute(DeliveryLease),
Publish(StagedPublication),
Busy {
evaluation_id: ControllerEvaluationId,
retry_at_unix_millis: i64,
},
Duplicate {
evaluation_id: ControllerEvaluationId,
},
BindingConflict,
}
}
Execute grants ownership. Publish hands back a result a previous owner already froze, so the
retry publishes rather than recomputes. Busy says someone else holds it and when to come back.
Duplicate is reserved for terminal, durably completed work, and BindingConflict for a
delivery whose identity does not match what the record already holds under that key.
Ownership is fenced rather than timed. The lease carries a monotonic fence, and the deadline in
it is advisory for a reason worth repeating:
#![allow(unused)]
fn main() {
/// Advisory deadline; only the ledger transaction decides ownership.
pub expires_at_unix_millis: i64,
}
A worker that believes its lease is live is not the authority on that. The transaction is. That one decision removes the class of bug where two processes disagree about a clock and both publish.
Three rules follow and are pinned by test. An owner whose lease has expired cannot save a new result. A result saved before expiry stays publishable on retry, because the work was real and the clock running out later does not make it wrong. A retained completion marker is repeatable without granting new work, so a redelivery is answered from the record instead of evaluated again.
The contract is
controller/src/orchestration/ledger.rs
and the operator view is Controller delivery.
The record and the runner share one lease
Two components with two ideas of who owns a delivery will eventually both act on it, and the result is either a duplicate verdict or a stale one overwriting a current one. The usual fix is a longer timeout, which converts a common bug into a rare one and makes it harder to reproduce.
The controller record and the runner share a single lease contract instead. The runner renews before its relative lease window closes rather than after, and losing ownership stops the run rather than letting it continue to publication. Ownership loss is a stop condition, not a race to finish first. A worker that was paused long enough to lose its lease finds out at the next transaction, and the work it was doing is dropped rather than published late.
Every accepted delivery carries a replay lifetime
Replay suppression needs an end. Keep every delivery identity forever and the record grows without bound. Forget one too early and a signed request that is still valid becomes replayable, which is a security hole wearing the costume of a cleanup job. The question is who decides when forgetting is safe, and the answer cannot be whoever is asking.
Trusted ingress stamps each accepted delivery with a lifetime at admission, derived from what the request itself can prove. A delivery authenticated by exact body, or by a scheme that only proves replay identity, is permanent, because nothing in it says when it stops being valid and guessing an end would be inventing a fact. A delivery carrying an authenticated ID and issue time gets a fixed end computed from the controller’s signed-age and queue ceilings, so the end comes from configuration the operator set rather than from the sender.
A route may narrow freshness beyond that. A route may not extend the lifetime already stored. That asymmetry is the whole point: the strict direction is always available, the permissive one never is, so no per-route setting can quietly reopen a replay window the controller closed.
The Gitea family is the concrete case. Its native webhook signature covers the body and nothing
else, with no timestamp anywhere in the delivery, so its replay markers are permanent and the
provider page says so rather than implying a window that does not exist. Ingress is
controller/src/ingress.rs
and the signature schemes are
controller/src/webhook/.
The record is ordinary files, not a database
A controller that needs SQL to hold its own delivery record inherits a service to run, back up,
patch, and secure, and it inherits it inside the trust boundary. For a component whose whole job
is to be harder to lie to than a CI job, that is a poor trade. FileLedger implements the entire
contract with ordinary files, cross-process advisory locks, and atomic replacement.
The root carries its own configuration. .amiss-root.state fixes the record cap and the replay
window and preserves a high-water clock, so reopening a root with different limits, with capacity
missing, or with damage that was never marked, fails closed instead of proceeding on assumptions.
Two processes cannot disagree about the shape of the record, because the record states its shape.
Capacity lives in a separate checksummed frame, .amiss-capacity.state, holding a slot count
that never understates use plus one exact pending key. The asymmetry is deliberate: a count that
is too high refuses a row that would have fit, which costs a retry, while a count that is too low
overfills the root, which costs the bound. The pending key means an addition interrupted by a
crash can be settled from that one row rather than by rebuilding the count from the directory.
Deletion is batched. One recovery marker and one final count write replace syncing the
bookkeeping for every row, so removing a thousand ended rows is one durable decision rather than
a thousand. Reading a root a previous version wrote is a real case rather than a hypothetical, so
v0.9 metadata migrates in place. The store is
controller/src/file_ledger/
and the operator view is The file ledger.
Lock growth is fixed and admission does not scan
A record that takes a lock per row, or counts the directory on every insert, gets slower exactly as it gets busier. On v0.9.0 that was measurable: admitting one new row into a root holding 100,000 retained entries took about 57.5 milliseconds, because admission counted what was already there.
The lock set is fixed and small. One maintenance lock, one admission lock, one clock lock, and at most 256 lazily created row-lock shards:
.amiss-root.state .amiss-maintenance.lock
.amiss-capacity.state .amiss-admission.lock
.amiss-clock.lock .amiss-row-7a.lock
Row locks are named by one hex byte of the row key, so the count is bounded by the shard space rather than by the number of rows, and a busy root creates the same 256 files a quiet one does.
Admission stops scanning. The checksummed capacity frame answers “is there room” without reading the directory, and the same measurement fell from about 57.5 milliseconds to about 0.25 milliseconds, with a full-capacity rejection at about 0.085 milliseconds. New identities are admitted under the configured cap while work already inside the cap is allowed to finish, so filling up refuses new arrivals rather than stalling what is already running.
FileLedgerRoot moved preparation and cleanup out of the request path. A service prepares and
cleans the root once, then creates independent fenced owner sessions without repeating startup
maintenance per request. Each new row also takes a fresh random evaluation suffix, so an old
retry cannot match a later row that happens to reuse a key after a safe deletion.
The measurement is not a merge gate, because a machine-specific timing threshold is a flaky test
wearing a stopwatch. A weekly release-mode run records admission, full rejection, and cleanup
separately at 1,000, 10,000, 50,000, and 100,000 retained entries, and the numbers are kept as
evidence rather than asserted. The run is
.github/workflows/bench.yml.
A row is one bounded state file and, briefly, a report
Unbounded per-row storage is how a broken or hostile provider fills a disk, and a partial write is how a restart resumes into a state that never existed. Both are ordinary failures, so the row format assumes them.
Each row is one bounded state file, plus one bounded report file for as long as the report is needed and no longer. Neither can grow past its ceiling, so a row’s cost is known before it is written rather than discovered afterwards.
Write order carries the invariant. Saving a result writes the report before the state that names
it, so no state file ever points at a report that is not there. Completion writes done before
removing the report, so nothing removes the evidence while a claim on it could still be made. A
crash between any two steps leaves a state the next open can recognize, which is the property
that matters: not “this cannot be interrupted”, but “an interruption is legible”.
Both opening the root and explicit cleanup remove dead reports and known atomic-write leftovers,
so the debris of an interrupted write is collected by ordinary operation rather than by an
administrator noticing. The format is
controller/src/file_ledger/format/.
Cleanup removes only what is safe to forget
Cleanup is where a durable record usually gets quietly wrong. Too eager and it reopens a replay window or deletes work in progress. Too shy and the root grows until admission starts refusing real deliveries.
The rule is narrow on purpose: only completed rows whose authenticated replay lifetime has ended are removed. Permanent completion markers stay, because a delivery with no provable issue time has no safe end. Running work stays. Saved results stay, because a result frozen before its owner expired is still the answer for that delivery.
Clock movement is the subtle case. A local clock that jumps backwards would make expired work look live again, so the persisted high-water clock in the root metadata refuses to go backwards and a rollback cannot reopen anything.
The pinned cases are the honest measure of the rule: the inclusive end of the window, clock
rollback, permanent retention, preservation of running and saved work, fixed lock growth,
behavior on a full root, recovery from an interrupted capacity update, and cleanup’s own
fail-closed root scan. Cleanup that cannot read the root does nothing rather than assuming the
root is empty, which is the difference between a maintenance job and a data-loss incident. The
transitions are
controller/src/file_ledger/transitions/.
Provider-verified controls
Closed July 2026. The engine report is self-asserted, so the gate had to become an object the provider owns and the checked repository cannot forge. Most of it landed in #107; the corrections that live instances forced came later, in #131 and #132.
One evaluation contract, not one per provider
Two problems shared one cause. The contract named provider-specific identity types, so describing GitLab meant growing a second shape and a provider enum sat in the middle of the trust boundary, where adding a provider means editing everything that matches on it. Separately, one ref was doing two jobs: the ref used to resolve URLs and the protected branch that controls apply to are different things, and conflating them means a check can verify one branch while the merge rule guards another.
The rolling contract separates the source ref from the protected target ref, and a frozen controller evaluation binds provider, integration, repository, URL dialect, change, refs, commits, trees, provider gate, check plan, execution limits, and trusted time, none of which requires knowing which provider is speaking. Providers differ in how those facts are obtained, not in what the evaluation says.
The change was mostly deletion. Opening the execution-constraint identity took 291 lines added against 169 removed; rolling the contracts forward removed 10,549 lines across 136 files while adding 2,499. Forge-shaped variants had accumulated in the wire types, the schemas, the examples, and the goldens, and most of the work was proving they were redundant rather than writing something new.
Opened in #57 and rolled forward in
#58. The types are
crates/amiss-wire/src/requests.rs.
The controller ships as source, not as a crate
The engine is published to crates.io and has no network capability at all, which is checked rather than claimed: a separate dependency policy bans HTTP clients, async runtimes, and socket crates from the engine’s graph, with reasons written into the file.
{ crate = "reqwest", reason = "the engine has no HTTP client" },
{ crate = "tokio", reason = "the engine has no async runtime and no sockets" },
{ crate = "socket2", reason = "the engine opens no sockets" },
The controller does have network capability, credentials, and provider tokens. Publishing it as a
convenient dependency would put all of that one cargo add away from anyone who wanted the
scanner, and would make the scanner’s dependency graph the union of both. So the
controller/ workspace stays unpublished
and source-built. An operator who wants a provider lane builds it from a commit they chose.
Inside, provider differences live in small crates rather than in a closed provider enum: provider-neutral traits and the orchestrator, a bounded ingress gate, a rotating key ring, signed-webhook checks, GitLab OIDC checks, and one adapter crate per provider family. A fourth provider is a new crate, not a new arm in every match statement.
Introduced with the controller foundation in #98 and folded into a single workspace in #123, which kept the two dependency graphs separate while removing the duplication two independent workspaces had caused: 3,352 lines added against 4,474 removed.
The bootstrap takes canonical documents and nothing else
The bootstrap is the trusted edge. A provider lane acquires objects, then hands them to this binary, and whatever it accepts is what the engine ends up believing. Every format it tolerates is a format an attacker may write.
It accepts three canonical documents, checks their required bindings, and passes their exact bytes to the verified engine in one closed input frame: the evaluation, the snapshot, and the controls. Bytes in, bytes out, with no reformatting step in between where a difference could hide. The documents have published schemas rather than being an internal convention, so an operator can validate what a lane will present before presenting it. The same wire library produces canonical execution limits and trusted-time statements, so the documents a lane presents come from the code that validates them rather than from a second implementation that agrees until it does not.
The executable itself is bounded at 33,554,432 bytes:
#![allow(unused)]
fn main() {
pub const BOOTSTRAP_EXECUTABLE_BYTES: u64 = 33_554_432;
}
That bound is load-bearing in a way that only shows up in practice. A fixture binary that linked
one crate too many crossed it during this project’s own lane testing and every run refused with
Unavailable rather than running an unbounded executable, which is the ceiling doing its job on the
person who set it.
The crate has shipped since #1; it learned these
documents with the sealed evaluation foundation in
#98. It is
crates/amiss-bootstrap/.
Authenticate first, save the raw bytes, then acknowledge
Two ordering mistakes are easy to make in a webhook receiver, and both are quiet. Parse before authenticating, and a parser meets hostile input for free. Acknowledge before storing, and a restart in the wrong millisecond loses a delivery the provider will never send again.
The receiver authenticates before admission and saves the exact raw delivery before acknowledging it. Raw means the bytes that were signed, not a re-serialized version of them, because a signature covers bytes and anything else is a different document.
The inbox is ordinary files, like the delivery record it feeds, and carries the properties that make a queue survivable: it outlives a restart, enforces both row and byte capacity, renews ownership while the controller works, retries temporary provider failures rather than treating them as verdicts, and removes the raw bytes only once the delivery ledger has completed. That last ordering means the bytes outlive every state that might still need them.
The listener is bounded before any of that: a fixed body ceiling, a fixed header count and header byte budget, and a delivery permit taken before the body is read and held through durable admission, so the memory a hostile sender can commit is decided by configuration rather than by the sender.
Completed in #107. The receiver and inbox are
controller/service/src/.
Objects are fetched by exact name under fixed limits
Fetching a branch and trusting what arrives lets the remote choose what gets scanned. Fetching without limits lets it choose how much memory the controller uses. Both are the same mistake: letting the answer decide the question.
Acquisition speaks Git protocol v2 with exact authenticated SHA-1 wants for the repository commit and the pinned action commit, so the remote answers a question rather than proposing an answer. One deadline covers network receipt and validation together, because a deadline that stops at the socket lets a slow validator hang after a fast download.
The pack limits are fixed constants, not configuration, and every one of them fails closed:
#![allow(unused)]
fn main() {
pack_bytes: 2_147_483_648,
objects: 2_000_000,
object_bytes: 134_217_728,
inflated_bytes: 4_294_967_296,
resolved_bytes: 4_294_967_296,
delta_depth: 128,
}
REF_DELTA is rejected outright, since a delta against an object outside the pack is a request to
resolve something the sender did not send. Pack indexing uses one thread, which trades throughput
for a bounded and reproducible cost profile. The protocol client is
controller/git/src/protocol.rs.
The runner seals the job it supervises
Between acquiring objects and trusting a result there is a process to start. A process is where inherited environment variables, inherited file descriptors, and leftover child processes turn into a trust problem, and none of those show up in a happy-path test.
The runner rechecks the acquired repository and action roots rather than trusting that acquisition put the right things there, derives a sealed job, and checks the pinned bootstrap against its expected digest. It prepares private inputs, clears inherited environment and streams, and supervises one cross-platform process tree. The controller owns the output handles, so the child writes into handles it did not open and cannot redirect.
Reading the result is equally distrustful. Wall-clock and lease limits both apply, whichever ends first. The output tree is proven empty before the run, so a leftover file cannot be read as this run’s answer. The report is bounded, and an incomplete or malformed result is rejected rather than parsed optimistically.
The pinned failures are the shape of the guarantee: wrong roots, bootstrap tampering, bad output, missing output, oversize output, timeout, heartbeat loss, and live descendants. That last one matters most in practice, because a child that outlives its parent is how a runner leaks work into the next job.
Sealed in #106. The runner is
controller/src/bootstrap_runner.rs
and acquisition is
controller/src/acquiring_runner.rs.
The GitHub lane runs one repository end to end
A provider lane is only meaningful if an operator can stand the whole thing up: one repository, one App installation, one protected branch, and a service that fails loudly when its configuration is wrong rather than at the first webhook.
The source-built GitHub service completes that lane on GitHub.com or a compatible GHES release. Strict JSON loads the App key, the rotating webhook secrets, the external controls, the execution constraint, the bootstrap, and separate private state roots, refusing any field it does not recognize instead of ignoring it. An unknown key in a trust-boundary configuration is either a typo or an attempt, and neither should be silently dropped.
The listener speaks plaintext by design and is deployed behind an operator-owned TLS and connection-limit boundary. That is stated rather than assumed, because a service that pretends to terminate TLS while sitting behind a proxy that also does invites exactly one confusion.
The App identity is what makes the gate real: the required status is bound to the App’s integration
id in the repository ruleset, so no other actor can post the check that satisfies it. The service is
controller/github-service/
and the setup is GitHub.
The GitHub source accepts four events and binds them
Accepting every webhook a provider offers widens the attack surface for nothing. Most events cannot change what a documentation check would conclude, and each one accepted is another payload shape that has to be parsed safely.
The source accepts signed opened, reopened, and synchronize pull-request events:
#![allow(unused)]
fn main() {
const SUPPORTED_ACTIONS: [&str; 3] = ["opened", "reopened", "synchronize"];
}
edited is accepted only when the signed payload says the base branch changed, because that is the
one edit that moves what the check is about. An edited title is not a new evaluation.
Admission then binds the configured repository and target, so a correctly signed event for another repository is refused rather than evaluated. Signature validity answers “did GitHub send this”, not “is this mine”.
After admission the App client refreshes the exact repository, pull request, ref, commit, tree, and
test-merge facts from the API rather than trusting the payload’s copy of them, and requires a strict
active status rule whose context is bound to that App. It refreshes again before saving the result,
because the state that decides a verdict is the state at publication, not the state when the webhook
arrived. The source is
controller/github/src/lib.rs.
The verdict lands on the commit GitHub actually merges
A check attached to the head commit describes the branch. A merge queue merges something else, the test-merge commit, and a status on that commit takes precedence over the head. Publishing to the wrong one produces a green branch and an unchecked merge, which is the failure mode worth the most care in the whole lane.
Publication attaches success, failure, or cancelled to GitHub’s authoritative test-merge
commit. The summary binds the gate, provider run, refs, commits, trees, plan, execution constraint,
report digest, and a stable unavailable reason, so the Check Run says what was evaluated rather than
only how it ended. A reader who distrusts the verdict can reproduce the inputs from the check
itself.
Idempotency is honest about its limit. The evaluation ID reconciles one exact visible retry, so an ordinary retry updates rather than duplicates. A create that GitHub accepted but whose reply was lost can still leave a duplicate, because GitHub and the local ledger do not share a transaction, and no amount of local bookkeeping fixes that. The page says so rather than implying exactly-once.
A final pull-request refresh turns an out-of-order publication into a no-op once its staged head,
base, refs, or gate is no longer current, so slow work cannot write a stale verdict onto a newer
gate. Publication is
controller/github/src/live/.
The GitLab lane runs as a policy job on the merge train
GitLab has no App identity to own a status, and any project member can edit a job the project defines. A gate the checked project can edit is not a gate, so the usual shape, a CI job that posts its own result, does not survive the threat model.
The lane uses a pipeline execution policy owned outside the checked project, which injects the job into every enforced merge train. The checked project cannot remove it, rewrite it, or skip it. The service then authenticates the job’s short-lived OIDC token and binds its policy project and commit, job and pipeline, runner, merge request, repository, and exact train-result commit before trusting any provider state at all. Each of those is a way the job could be someone else’s, and the binding is what makes the token mean this run rather than any run.
The lane requires GitLab 19.3 or newer with Ultimate, because enforced merge trains are what make the policy job unavoidable, and they are generally available from 19.3. No live run is recorded yet: as of July 2026 the newest release is 19.2.0, so no supported instance exists to run.
The service is
controller/gitlab-service/,
the OIDC checks are
controller/gitlab/src/oidc.rs,
and the setup is GitLab.
The GitLab gate refuses anything but the exact saved pass
The policy job is a synchronous endpoint: it asks the service a question and merges on the answer. Anything other than a proven pass returning success turns the whole lane into decoration.
Refresh requires the configured merge method, exactly two train parents, an active policy job, a protected target branch with no push or bypass path, and merge-train enforcement for all users. Each is a way the shape being gated could differ from the shape that was verified. Two train parents in particular is what makes the train result the thing that merges rather than some other commit that happens to be nearby.
Then the endpoint refuses everything except the exact saved pass. Success is the exact HTTP 204
and nothing else. Block, unavailable, duplicate, expired, replayed, and changed state all keep the
policy job failed. There is no “probably fine” state, and no path where a missing answer reads as an
affirmative one. The rules are
controller/gitlab/src/live/refresh.rs.
The Gitea family lane publishes through a dedicated reviewer
Gitea and Forgejo have no App identity and no first-class status owner. The only gate available is an approval from an account nobody else controls, which makes the account itself a trust anchor: whoever can act as that reviewer can satisfy the gate without Amiss, and the provider page says so in those words.
The service authenticates the native exact-body HMAC, refreshes the pull request, commits, trees, effective branch rule, and reviewer identity, then publishes an approval or a request for changes as that one account. It supports Gitea 1.27 or newer and Forgejo 16 or newer.
Getting it to work against real instances took four corrections that no fixture had caught, because
each fixture was written to the API’s documentation rather than its behavior. Both families answer
/git/commits/{sha} with the commit’s own name in the commit’s tree field:
$ curl .../git/commits/436a6f35fd89b32d8661c6d7e12ba19960dfd841
sha : 436a6f35fd89b32d8661c6d7e12ba19960dfd841
commit.tree: 436a6f35fd89b32d8661c6d7e12ba19960dfd841
$ git cat-file -p 436a6f35
tree 5c1c95daa0e57e7a46ad6937d4b1515e0b5ff43f
No route on either family states the tree of a commit, so trees now come from fetched Git objects
through the same resolver the GitLab lane already used. Forgejo also sends one signature under two
spellings, X-Forgejo-Signature and X-Gitea-Signature, and the header reader treated the second
spelling as ambiguity and answered 401 to every real Forgejo delivery. A transient
mergeable: false, which Gitea reports for a second or two while it recomputes a merge, was being
read as a terminal verdict. And a control revoked between staging and publication left the lane
publishing nothing at all.
The service is
controller/gitea-service/
and the setup is Gitea and Forgejo.
The Gitea family gate is checked, not assumed
An approval gates a merge only if the branch rule actually requires that approval and closes every other way in. Those are separate facts, reported separately, and either one missing makes the approval decorative.
The gate requires one approval restricted to the dedicated reviewer, closed direct-push and bypass paths, stale and rejected review blocking, an up-to-date pull request, and administrator enforcement. The adapter checks the distinct Gitea and Forgejo capability shapes rather than guessing which forge it is talking to from headers, because the two report overlapping fields with different meanings and a wrong guess produces a confident wrong answer.
Two facts about this only surfaced against live instances. Reading the rule needs repository
administrator access, not write: below that, /branch_protections/{rule} answers 403 and the
branch route leaves effective_branch_protection_name empty, so the lane cannot read the rule it is
required to check. The documentation said write access, which cannot work. And the gate is verifiable
in both directions now: with the rule intact the lane approves, with direct push re-enabled it
publishes unavailable / authorization-revoked, and restoring the rule returns it to approving the
same content. The checks are
controller/gitea/src/live/refresh.rs.
The lanes are tested through, and against, themselves
A lane test that only walks the happy path proves the pieces connect. It says nothing about the cases a gate exists for, and those cases are the product.
End-to-end and focused tests carry a signed delivery through authentication, durable admission,
provider refresh, the runner, the provider gate, completion, and replay suppression. The negative
list is the real coverage: wrong provider, repository, target, runner, policy, reviewer, commit, and
tree; changed bootstrap or merge rule; expiry and replay; missing output and timeout; malformed or
tampered input and state; capacity and restart; lost ownership; ref or gate drift; oversized and
malformed packs; REF_DELTA; excessive delta depth; and conflicting provider evidence.
The limit of that coverage is worth recording, because live instances found it. Every double in these suites answers the way the provider’s documentation says it will. Gitea’s test double returned a tree name distinct from its commit name, which real Gitea never does. The Forgejo lane test sent one signature header, which real Forgejo never does. Both suites passed while neither provider could have worked, and no amount of adding cases to a double that agrees with the code would have found it.
What did find it was standing up real instances, which is why
Retained provider runs exists as a separate kind of evidence rather than
as more tests. The fixtures are still the right regression net: they run in seconds, they cover the
negative cases exhaustively, and they catch a change that breaks a lane. They just cannot tell you
the provider was never like that. The suites live under each service, such as
controller/github-service/tests/lane/.
Provider evidence lives in the provider, not in the report
A report that says it was verified is a report asserting its own trustworthiness. Anything that can produce the report can produce the claim, so the field would be worth exactly nothing and would read as though it were worth something. That is worse than omitting it.
So the evidence is an object the provider owns and the checked repository cannot forge: the App-owned
Check Run, the protected GitLab policy job, or the dedicated Gitea-family review, each paired with
the merge rule that makes it necessary. The engine report stays exactly what it was, self-asserted,
with no provider signature and no provider_verified field. Nothing was added to it, and that
decision is the one worth recording: the natural move when shipping provider verification is to
stamp the artifact, and the stamp would have been a lie.
Each provider page states its own commit or tree freshness limit, retry behavior, rotation rules, and full trust boundary, including which accounts and keys can satisfy the gate without Amiss. A trust boundary that is not written down is a trust boundary nobody checked.
Live runs for GitHub, Gitea 1.27.0, and Forgejo 16.0.1, in both directions, are in Retained provider runs. The lanes are Provider-verified controls.
Provider operations
Closed July 2026. A lane that cannot be deployed, watched, or restarted without losing work is not finished, whatever its verdicts say. Five claims, all added in #121 and #122.
Every provider binary can check its configuration offline
Before this, an operator learned that a credential path was wrong by starting the service and waiting for a webhook. On GitHub it was worse: private-key and API-client validation happened at runtime, so a bad key surfaced during the first real delivery.
Every provider binary takes --check with an absolute configuration path. It runs the same
strict loader startup runs, over the same local credentials, trust anchors, controls, execution
constraint, bootstrap, and path layout, and validates the execution constraint against the host
it is running on. GitHub also constructs its App client during that load. Then it exits, before
service runtime, before mutable state is opened, before the bootstrap is executed, and before
any provider I/O.
It is the real loader rather than a second implementation, which is the only version of this feature worth having. A preflight that agrees with a configuration the service would reject costs trust rather than earning it.
What it does not claim is written down: not provider reachability, not credential permissions, not merge-rule correctness, not service readiness, and not retained live-provider evidence. It is account-free on purpose, so it can run in a pipeline holding no provider credentials at all.
The entry points are each service’s main.rs, such as
controller/github-service/src/main.rs,
and each service’s tests/config.rs covers the family.
Liveness and readiness answer different questions
An operator could not tell a live process from a serving one. /healthz answered before local
state was open, so an orchestrator would route deliveries into a process that could not accept
them, and neither restart nor credential rotation had an observable boundary.
The private listener separates the two. /healthz answers whether the process is running.
/readyz answers whether admission can currently accept a delivery, which is what a load
balancer is actually asking. While unready, provider work is refused with 503 rather than
accepted and dropped, so the provider retries into a service that will still be there.
Lifecycle transitions are written to stderr as one redacted JSON object each, and the schema is
deliberately narrow: schema, level, event, and component, nothing else. A log line that
can carry a repository name or a delivery identity can echo request data into an operator’s
aggregator, so this one cannot.
One operator consequence is stated rather than left implied: none of the three private endpoints
is authenticated. The listener belongs on loopback or an operator network, with only the
provider POST path published through a proxy. An unauthenticated readiness endpoint on a
public interface is a free liveness oracle for anyone who wants one. The endpoints are
controller/service/src/probe.rs.
Ten counters, no labels, no cardinality surprise
Metrics with provider-supplied labels let a provider decide how much memory the registry uses, which turns monitoring into a denial-of-service surface. A metric set that grows as the code grows becomes a compatibility surface nobody agreed to maintain.
/metrics exposes exactly ten fixed, label-free, process-local counters under the
amiss_controller prefix:
#![allow(unused)]
fn main() {
pub provider_requests: Counter,
pub provider_acceptances: Counter,
pub provider_refusals: Counter,
pub provider_unavailable: Counter,
pub delivery_attempts: Counter,
pub delivery_completions: Counter,
pub delivery_retries: Counter,
pub delivery_discards: Counter,
pub maintenance_runs: Counter,
pub maintenance_removals: Counter,
}
The set cannot grow from a repository, an identity, or a result, so nothing a provider sends can add a series. Lifecycle transitions are emitted as events rather than folded into counters, so a restart does not move a number someone alerts on.
Process-local is a deliberate limit, not an oversight. These counters describe one process since
it started. The durable record is the delivery ledger, which is designed for that job. They live
in
controller/service/src/operations.rs.
Shutdown finishes the work it already accepted
A service that exits on a signal drops the delivery it just acknowledged, and an acknowledged webhook is one the provider will not send again. Restarts are ordinary. Losing a verdict per deploy is not.
On a termination signal, in-flight HTTP work finishes. A webhook worker finishes its current delivery and leaves the durable backlog for the next process, which is the right split: the backlog is already in the inbox, while the delivery in hand has state only this process holds. The GitLab lane also finishes admitted evaluations and any running ledger maintenance, since maintenance interrupted halfway is what leaves a root needing recovery.
A second termination signal aborts the process rather than waiting on a stuck drain. That is the
escape hatch an operator needs at three in the morning, and it is documented so nobody has to
discover it by holding a key down. Drain is
controller/service/src/shutdown.rs.
Hostile provider input is tested without provider accounts
Robustness checks that need a live provider account run rarely, run late, and stop running when a token expires. The input worth testing does not need an account: it is bytes arriving at a listener.
Two fuzz targets construct valid signed GitHub and Gitea-family webhooks and valid GitLab OIDC material, then vary exactly one fact: an identity, a binding, a replay marker, or a freshness claim. Starting from a valid request and breaking one thing is what makes the result meaningful. Random bytes mostly test the parser’s first branch, while a correctly signed request with the wrong audience tests the check that matters. Committed seeds keep the corpus, a deterministic smoke lane runs in CI, and a nightly coverage-guided run goes deeper.
The same change removed a smaller problem. Four RSA private keypairs were committed in the tree as test fixtures. They were only test keys, but a valid private key in a repository is a finding in every scanner that looks, and explaining that forever is worse than fixing it. A fixtures crate now generates one pair per test process, which also proves freshness in a test rather than in a comment.
The targets are
controller/fuzz/fuzz_targets/,
with the keypair generator in
controller/fixtures/.
Reference coverage
Closed July 2026. The scan ledger had measured what the engine could not resolve across ten public repositories and sorted it into named classes. Naming a class is not answering it, and a class left named is a caveat a reader has to carry. This phase answered all four, and refused a fifth with the measurement that killed it.
Each answer had the same entry condition, the one the Markdown adapters already met: a pinned grammar, a conformance corpus, extraction goldens, resource accounting, and honest opaque regions. None of them entered on intuition, and the rescans are in the ledger beside the original counts.
A heading anchor belongs to the renderer, so twelve of them are pinned
## Setup & Config has no identity until something renders it, and renderers disagree. github.com
publishes setup--config, VitePress publishes setup-config, and Gitea publishes nothing at all if
the heading empties out under its filter. The engine used to decline every anchor for that reason,
which was honest and also meant 123 of the ledger’s missing rows were invisible.
Twelve rules are pinned, one per renderer or per configuration of one, and the resolver asks whether any of them would publish the anchor. The union is deliberate: adding a rule can only grow what an anchor may match, and no repository policy narrows it. A document can add to the set by declaring an identity the way it would add a heading, in raw HTML, in an attribute block, or in the MDX comment Docusaurus reads, which is an edit a reviewer sees rather than a setting that clears a finding.
Seven of the twelve have a runnable implementation, and against those the table reproduces all 9,049 headings harvested from the ten ledger repositories with no mismatch. The other five are transcribed by hand from their renderer’s source, and the published vectors say which is which and what each transcription is not checked against, because a rule that quietly stops matching its renderer looks exactly like one that still matches.
What the rules are and how far apart they sit is What twelve renderers call a heading. Published in #135, pinned in #137, resolved in #138, then extended over four more changes to reach raw-HTML headings, declared identities, the MDX comment, and the entity spellings a raw-HTML heading anchors under, ending at #156.
A destination is asked again under the spellings a router serves
A documentation site serves guide and guide.html for a file called guide.md, and a directory’s
README.md as its index. An author who writes the served spelling is writing a working link, and
the engine was reporting it missing.
A relative destination the tree does not hold is asked once more under those spellings. The first spelling that names a file resolves the reference, and the report names the file that answered while the occurrence keeps the destination the author wrote. The safety property is what makes the union acceptable: a spelling can only reach a file the tree already holds, so it widens what resolves and can never invent a target. A promised directory and a same-repository forge URL are never re-spelled at all.
The spellings were harvested from three routers rather than transcribed from documentation. Across the ten trees they moved 247 of the 516 missing references and moved nothing else. All 241 of starship’s were preset page names across twenty-two translations; mdBook’s six were its own output extension. The union, the routers it came from, and what it costs are in What a documentation router serves.
Split from the anchor class in #141, pinned in #142, answered in #143, measured in #146 and #149.
A generated target is answered from the declaration the repository already publishes
The largest class the ledger measured was targets a documentation build writes and a tree never
holds: 102 of ruff’s 104 missing rows, 63 of them into one docs/settings.md. The engine cannot run
a docs build, and a configuration file naming generated paths would be a new thing for maintainers
to write and for the engine to trust.
So it asks a declaration the repository already publishes for Git. Only the tracked .gitignore
files on the path’s own ancestor chain can answer, and a line qualifies only when it is anchored with
a leading slash, carries no pattern or escape byte, is neither a comment nor a negation, and spells a
path with no empty, ., or .. segment. The nearest file that names the path answers and travels
with the result.
The narrowness is the point. The engine never asks whether a path is ignored; it asks whether a tracked ignore file names exactly that path, because one wildcard would let a single line answer for an unbounded number of references. Git applies no ignore rule to a file already tracked, and neither does this, so a path the tree holds never reaches the question.
The outcome is target-declared-untracked, a record under both profiles rather than a cleared
finding, so the reference stays counted and the claim travels with it. Measured on the binary from
that branch: ruff moves 94 of 104 rows out of explicit-target-missing and keeps every one counted,
all declared by docs/.gitignore, and uv moves 54 of 55 from its root .gitignore. Both still exit
1 under enforce, because the leftovers are real.
Read in #171 and asked in
#172. The parser is
crates/amiss-scan/src/declared.rs.
AsciiDoc and reStructuredText are read by their own parsers
Both had sat as one roadmap candidate for months, and treating either as Markdown with different
punctuation would have produced confident wrong answers. Their reference vocabularies are their own:
xref:, <<id>>, link:, and include:: on one side, hyperlink targets and four file-naming
directives on the other, with roles left declared rather than guessed at because they are an open
extension point.
Each is read against the grammar its renderer defines. Docutils’ make_id and Asciidoctor’s
Section.generate_id became the eleventh and twelfth anchor rules, so a heading anchor on either
document type resolves the same way a Markdown one does.
Two AsciiDoc behaviors needed rules of their own, and Quarkus found both. The first end-to-end run
over its 355 documents reported 1,098 missing targets and not one was real. A target still holding a
{name} attribute cannot be a path, because the value arrives when the site is built and this engine
reads two trees; across Quarkus that is roughly a quarter of every reference. Charging those as
unsupported semantics rather than as misses left nine, every one a .adoc file the tree genuinely
does not hold. Enabling anchors then surfaced the second: a document that transcludes another
publishes a partial anchor set, because include:: splices before Asciidoctor parses and this engine
does not splice. Quarkus produced 127 of those, and an anchor absent from a transcluding document is
now undecided rather than absent.
The later bounded local graph narrows that boundary for option-free relative includes whose targets are scanned under the same grammar. Attribute-dependent and selected includes remain partial, and expanded AsciiDoc still proves presence rather than absence because the build owns its attribute and conditional state.
Both are described in Resolution. AsciiDoc landed over five changes from
#177, which first counted the markup the engine could
not read, to #181, which published the identity
Asciidoctor gives a section. reStructuredText followed the same three steps in
#183,
#184, and
#185. The crates are
crates/amiss-adoc/ and
crates/amiss-rst/.
The fifth candidate was measured and refused
Inferring a reference from a bare filename in prose was the obvious next widening, and it is the one this phase declined. Across three trees the strongest available signal is a path-shaped token inside a code span, and 55 to 85 percent of those name nothing in the tree. Requiring a slash lowers the rate rather than raising it.
What the non-resolving pile holds is documentation’s own teaching examples. ruff’s twenty-two most
frequent are main.py, a.py, b.py, mypackage/__init__.py and their kind: 564 mentions that
were never references and can never be fixed. A tool reporting them would file more than a thousand
rows against ruff to surface the ten real missing targets the explicit checker already finds, and it
would be worst exactly where documentation is densest, because the pages that teach with examples are
the pages full of filenames that do not exist.
The refusal went into What Amiss is not rather than staying on the roadmap, so the next person who proposes it from intuition meets the measurement first. Dropped in #176.
Live provider evidence
Closed August 2026. A provider lane tested only against local HTTP fixtures answers the way its author expected the provider to answer, so the fixtures are regression tests and not evidence. This phase retained runs against the providers themselves, one positive and one revoked-control pair per lane, with the candidate content held fixed inside each pair so the verdict could flip for exactly one reason. The rows live in Retained provider runs, which stays the live chapter; this page records how the phase closed.
GitHub, Gitea, and Forgejo closed in July 2026 against github.com, a self-hosted Gitea 1.27.0, and a
self-hosted Forgejo 16.0.1, controller 2dbb0b6, action tree ca5b2b24f3c3. Drift verdicts from
the same instances, where the candidate broke a documented reference and the control stayed intact,
are kept in pull request 131.
GitLab closed in August 2026 without waiting for the self-managed release: gitlab.com deploys ahead
of it and was running 19.3.0-pre under an Ultimate trial, and the lane’s floor is structural
rather than a version compare, so the served merge_train_enforcement field was the whole test.
Controller d6d42de, action tree b4b576da at v0.17.0, an enforced merge train on a protected
project, and the pipeline execution policy pinned by commit. The enforcement control was revoked to
allow_bypass for the second row, the policy job failed on 412 and the train dropped the car, and
the restored train then merged the same content.
The campaigns kept paying in the same currency: six defects across the four lanes that every fixture
had agreed with. July’s four are recorded with the July rows. August found two more, the documented
policy job wrapping its script across YAML lines that policy injection preserves literally, fixed by
keeping the command on one physical line in the lane page, and gitlab.com
answering the jobs API with a null source for the policy job, fixed by accepting an absent REST
source while the signed OIDC job_source claim and the pinned job_config binding continue to
state the provenance.
What the phase defends: every supported lane’s trust story now rests on verdicts the provider published and still holds, not on what a fixture author believed. The milestone that was still open when this page closed, the wire leaving experimental, has since completed too: A settled wire records the freeze.
A settled wire
Closed August 2026. A machine consumer of the report could not build against a contract
that reserved the right to reshape under it, and pinning an engine release only moved the
problem. This phase gave the wire its own version, decoupled from engine releases, ran a
public quiet period under a mechanical tripwire, and froze the contract at 1 when the
last condition closed. The report stays the live chapter; this page
records how the freeze was earned.
The mechanism shipped first, in v0.18.0. compatibility is the payload’s own version and
travels inside every report, so a consumer reads stability from the report rather than
from the version of the binary that wrote it. Engine releases were declared to mean
engine behavior only. The clock got a tripwire in the tree: the example the last release
shipped is retained beside the rolling one, refreshed by the release workflow, and a
contract test fails the build the moment it stops clearing the current schema and reader,
which is exactly a reshape.
Three conditions had to hold at once, and each has its own exit evidence. Every supported lane’s trust story closed on retained live evidence, recorded in Live provider evidence. The release carried no half-built trust path: the launcher placeholder was cut, and the verified-consumption lane is the attestation recipe in Security model, checked against every release the workflow publishes. And two consecutive minor series shipped without reshaping the payload: v0.19.0 and v0.20.0 carried the fix, claim, and adopt verbs, two report projections, and a policy grammar binding, all additive, with the tripwire green through both.
The freeze itself is additive law made mechanical. The writers emit 1 from one wire
constant, the schema pins the same value by a contract test, and the first frozen example
is retained permanently at
spec/examples/scanner-report.frozen-1.json,
byte-pinned against edits, with every later schema in the major required to keep
validating it. A 1 report may gain optional fields as 1 rolls forward; nothing a
1.0 consumer parsed ever changes meaning or disappears. Reshaping past that promise
mints 2, and since a new contract breaks consumers whatever the binary is called, that
release is a major one. The engine’s own 1.0 remains a maturity statement about the
engine, made on its own grounds; the wire did not wait for it and does not follow it.
Projection contracts
Closed August 2026. The scanner could establish that a reference reached a repository object, but it could not hold visible documentation equal to the exact source or producer-owned value it claimed to show. This phase added one policy-owned relation instead of a family of code-, table-, and language-specific checkers. The live contract is in Controls and policy and Trusted semantic evidence; this page records why the relation is narrow.
The first step closed the completed-site fragment rules against raw and percent-decoded anchors,
legacy names, redirects, invalid escapes, and duplicate targets in
#570. A projection then received one stable
(document, name) identity, one typed source, one adjacent visible sink, and one projection kind in
#571. The scanner evaluates that closed relation in
#576, while
#577 makes removing it a policy weakening rather than
letting deletion erase the obligation.
Exact source selection, not a formatter language
A source is already meaningful before it reaches a sink. Exact blob lines and the named regions from #572 select repository bytes. The shared typed projection primitives from #573 let a complete tree-path selection become sorted rows in #579 or a canonical decimal count in #574. Selection reuses the snapshot discovery the scan already paid for; named regions use one exact ordered marker pair and never run a regex or repository process.
Comparison normalizes line endings and removes exactly one terminal newline, but preserves every other byte. Sorted rows retain duplicate multiplicity and ordering defects. The two-pointer difference in #580 reports exact totals and bounded missing and extra previews instead of copying an unbounded mismatch into the report. The grammar is closed: there is no template language, arbitrary table schema, or source parser hidden in a sink.
Completeness controls what absence can prove
The same evaluator consumes producer-owned records. A present row can project one exact value after #583, but an absent row means absent only when the producer declared that exact set complete. Complete sets can project all display values or their count after #584; partial sets cannot prove equality, extra rows, counts, or absence. Keys remain identity even when two rows display the same value.
Projection work has its own ceilings rather than borrowing the scanner’s aggregate memory bound. #582 meters assertion count, selected and projected bytes, compared records, and copied previews independently. The retained measurements cover equal, first-different, last-different, all-different, large-row, and shared-source inputs; the limits came from integrated release runs rather than an estimate.
Local authoring does not create authority
The public scanner can bind one candidate-free semantic template to the exact invocation candidate
after #585. The offline authoring command added by
#586 bounds and canonicalizes specialist key/value
rows into that template. Neither path executes a producer, authenticates its claims, or changes the
report from self-asserted; provider authority still requires an independently planned acquisition.
Two tempting extensions deliberately did not close. A fixed two-column table has no retained user, so code blocks and exact row projections remain the visible forms. Automatic projection rewrites have no retained real findings that prove stable replacement identities; until they do, Amiss reports the exact drift and does not rewrite the sink.
Authoritative semantic artifacts
Closed August 2026. External producers can know facts the repository tree cannot, but trusting only their normalized answer would make a later report impossible to audit. This phase bound the operator’s expectation, the provider run, the exact acquired bytes, the candidate-bound envelope, and the retained report into one replayable chain. It also proved that a language specialist can enter that chain without putting a language parser in the engine or provider services. The live contract is Trusted semantic evidence.
The input survives the producer artifact
#587 records the exact candidate-independent template and the canonical candidate-bound envelope, including their acquisition and digest identities. #588 retains those bytes with the accepted report so restart and retry never reacquire mutable output. #589 adds authenticated retrieval and a provider-visible locator, allowing the audit lifetime to outlive the workflow artifact that supplied it.
The repository does not choose what workflow is trusted. The immutable expectation from #590 fixes provider, repository, workflow, event, artifact and sole payload names, producer context, and archive and file limits. GitHub archive decoding in #591 refuses traversal, links, duplicate or extra members, and decompression growth. Exact run selection and bounded signed download arrive in #592, service wiring in #593, and authenticated completion scheduling in #594. One scanner lease never polls an entire build.
The first typed specialist stays outside the engine
The unpublished Rust specialist reads bounded, format-matched Rustdoc JSON and emits one complete
record-set@1; it invokes neither Cargo nor Rustdoc. #595
normalizes root-crate public free functions, and
#596 adds inherent and trait declarations with
disjoint stable keys. Public aliases come from the maintained Rustdoc adapter. Numeric Rustdoc IDs
never become identities, and ambiguous specialized inherent functions are refused rather than
disambiguated by parsing rendered Rust syntax.
Real format-matched Rustdoc, Cargo feature boundaries, and host versus wasm32-unknown-unknown
target boundaries are pinned by #597,
#598, and
#599. A configuration is complete only for its exact
compiler, format, package, target, triple, features, cfg, and dependency digest. The generic
record-value and record-set projection contract attaches those declarations to visible docs in
#600; no Rust-specific scanner finding or evaluator
was added.
Unsupported producers remain explicit
Only GitHub has a planned workflow-artifact acquisition path. Gitea/Forgejo and GitLab remain held until an operator supplies a real artifact API, credential model, and workflow contract. A second completed-site producer remains held for the same reason, so no speculative Sphinx/MkDocs assembly was extracted from the proven mdBook path.
The Rust specialist deliberately stops at functions. The maintained dependencies do not yet expose the larger public item surface as structured stable kind, path, and declaration rows from already bounded bytes; Amiss will not parse another crate’s rendered text or carry a fork to pretend that gap is closed. Executable-example receipts and generated-diagram projections also remain demand gated. Neither summary JUnit nor exact image bytes would by itself prove the semantic claim those features are often assumed to make.
Offline audit sidecars
Closed August 2026. A repository report cannot prove what was later deployed, which product a site
describes, or whether two locale inventories cover the same pages. Putting those facts into the
scanner report would give one candidate a claim over events outside its trust boundary. This phase
instead added closed report-bound sidecars with the same conservative matched, refuted, and
unproven vocabulary. Publication audits and
Locale coverage audits own the live contracts.
Publication is an exact relation, not a URL guess
The publication plan in #601 binds the accepted report, docs candidate, completed-site artifact, deployment target, exact product resource, producer, and operator relation rule. Provider-normalized evidence from #602 repeats those identities beside one immutable successful deployment record and workflow definition. URLs, tags, timestamps, and similarly named channels never substitute for resource digests.
#603 assesses the pair offline through one Result
flow. Failure, missing evidence, mutable identity, or any mismatched binding cannot become a match.
The controller independently validates that chain against its accepted scanner report in
#604, then
#605 retains and reopens the exact report, plan,
optional evidence, assessment, and digest set. Retry replays bytes; it does not rejudge a changed
deployment.
Locale coverage separates presence from provenance
#606 first extracted the shared bounded, digest-bound sidecar envelope before adding another audit family. The locale plan from #607 then fixes opaque stable page keys, source and target locales, coverage policy, producer context, and an optional exact product resource. Independent complete or partial inventories arrive in #608, and the bounded assessment in #609 reports missing and orphan keys only where completeness permits it. A present key proves coverage, not translation quality.
Fallback cannot masquerade as target-owned content after #610: every fallback has an operator-authorized opaque class and exact source digest. #611 compares explicit producer-owned source lineage and refuses to infer it from time, text similarity, or Git adjacency. #612 reuses publication’s exact product resource on both inventories, so a locale version label cannot impersonate a release identity.
The offline boundary is part of the result
No command or controller lane currently acquires locale evidence. Publication has controller validation and durable retention, but no provider lane authenticates deployment completion or publishes a post-deployment audit. GitHub, GitLab, and Gitea-family deployment integration remains operator gated because their environment, artifact, credential, completion, and public-destination contracts are not interchangeable. The milestone is the reusable offline proof and retention core, not a claim that a live deployment can already invoke it.
Cross-repository relation core
Closed September 2026. Documentation and code can live in different repositories, but neither repository is allowed to choose the other repository, its credential, the comparison, or where a verdict is published. This phase built the provider-neutral relation lifecycle beneath that rule: four exact snapshots, one symmetric projection transition, durable supersession, and restart-safe status delivery. Cross-repository relations is the live contract; this page records what the core proves and where live assembly still stops.
One operator plan owns four exact snapshots
The immutable two-subject registry in #613 fixes both provider scopes, repositories, branches, credentials, selectors, budgets, trigger ownership, and status destinations atomically. #614 acquires each base/candidate pair into a physically independent Git root under per-subject and aggregate streaming limits; an unavailable or unverifiable subject leaves the complete relation unproven.
The portable audit is split into a plan, four-slot projection evidence, and a replayable equality transition by #615, #616, and #617. It can say aligned, introduced drift, pre-existing drift, resolved drift, or unproven without appointing either repository as truth. The pure tree projector and controller projection path land through #618 and #619, with operator-context binding in #620. Accepted-report decoding is shared in #621, the complete chain is independently replayed in #622, and component derivation plus immutable retention close in #623 and #624.
Coordination is opaque and supersession is fenced
The operator-supplied coordination identity added in #625 may mean a pair, release, or workflow occurrence; Amiss never infers it from timestamps or nearby heads. The pure admission law in #626 preserves an exact retry, rejects identity rebinding, and advances a fence when new coordination supersedes pending work. #627 persists the same law in a bounded hash-chained journal whose committed head makes interrupted appends recoverable and committed mutation visible.
Fresh two-subject heads freeze only configured destinations in #628, and #629 reserves every external status key to one relation. Pure fenced staging arrives in #630; a tagged journal action grammar in #631 lets the durable outbox land in #632. Restart reopening, per-destination durable acknowledgements, and serialized oldest-fence claims follow in #633, #634, and #635. Dropping a claim mutates nothing; unrelated lock shards can still publish in parallel.
Provider boundaries preserve provider semantics
GitHub exact head resolution and idempotent App-owned check runs land in #636 and #637. Gitea-family commit statuses arrive in #638 with their lack of writer-bound merge-gate identity stated rather than hidden. GitLab’s #639 keeps its result synchronous and bound to the authenticated active policy job instead of pretending it has the same asynchronous status model. Gitea-family exact head resolution follows in #640.
Service assembly is likewise provider neutral: strict registry loading in #641, exact credential routing in #642, and authenticated coordination admission in #643. Exact provider heads are retained and frozen in #644 and #645; canonical audit construction and the projection, assessment, retention, and staging pipeline land in #646 and #647. The provider-neutral loop resumes durable claims and acknowledges only reconciled destinations after restart in #648.
The provider binaries still do not construct and install this registry, credential router, snapshot acquisition, and lifecycle as one live lane. That topology needs an operator contract rather than a hidden cross-provider default. Post-publication wiki drift also remains demand gated and would be a separate Git subject observed after publication, never a pre-merge guarantee. Privacy-safe external URL histories, organization-scale routing, and portable signed outcomes remain research until real operators supply their retention, isolation, and trust-root requirements.