The report
--format json writes exactly one line to stdout: the canonical JSON of the report, then a
newline. Canonical means RFC 8785 canonical JSON:
keys sorted, one byte sequence per possible document, so the same input through the same
engine binary always produces the same bytes. The payload facts agree across platforms; the
envelope’s own digests differ by build, because they name the exact binary that ran. Duplicate keys are rejected everywhere on input, and
the contract’s numbers are integers, never floats.
The outer envelope has three members: its schema, the payload, and payload_digest, a hash
of the payload’s canonical bytes. The payload carries its own schema, compatibility
(the wire’s own version, 3 since the tolerant-reason reshape), and an engine block whose engine_digest names the
binary that produced it. Every digest in the system is domain-separated, meaning the hash
input starts with a label naming its purpose, so a digest computed for one context cannot be
replayed as a digest for another.
Inside the payload: which trees were compared and how; the result block with status,
complete, and exit_code; the PR-facing feedback projection; the summary counts; a documents array with one row per
discovered document, its classification, and whether its content was available; the
findings array; and the errors array of analysis errors the run kept.
Both report readers use the same result model and verdict check. They require every result
member and reject unknown members, negative counts, and disagreements between complete,
status, and exit_code. Bootstrap also checks the recorded finding count against the array.
Summary and result counts are limited to 0..=9007199254740991 by their Serde models on
both input and output. They remain ordinary u64 fields in Rust; serialization refuses an
out-of-range count rather than emitting a number the report reader cannot accept.
Bootstrap decodes the complete report once, including document, observation, finding and error rows, and passes the typed evaluation and controls through verification. After LF framing, malformed fields fail the shape check before canonical bytes, digests and semantic bindings are checked. Validly shaped engine, identity, control and verdict mismatches retain their specific errors. Exact canonical byte equality prevents accepting a differently shaped input with the digest of its typed normalization. Unknown payload-root fields are rejected by both readers.
The public reader also decodes the complete model. It accepts whitespace, member reordering
and equivalent JSON string escapes, but not changes to the data’s shape. At ingress,
serde-transcode feeds the original JSON directly to the canonical writer; a domain-separated
SHA-256 check compares that envelope with the typed envelope before its payload digest is
trusted. This prevents a positional array, dropped member or normalized field from borrowing
the digest of a different report, without retaining a dynamic payload or staging two output
buffers. Malformed fields and unsupported compatibility tags return the report-shape error;
validly shaped payload-digest and result-tuple mismatches retain their specific errors.
Engine, action, adapter and summary metadata also use closed models. Unknown members are rejected even with a matching payload digest. Bootstrap validates the summary’s declared fields as well as the engine block; a local action tag cannot conceal a forge-action body.
Document, feedback and analysis-error rows reject unknown members too, including nested
source spans and byte-path objects. An unavailable feedback tag cannot hide available items.
Writers retain explicit nullable members. Direct Serde decoding treats an omitted ordinary
Option<T> field as None, like null; report admission still checks the original envelope
against its typed canonical representation, so omitted output members cannot borrow its digest.
Resolution tags select closed bodies: fields belonging to another kind are rejected, and targets, blob evidence and version scopes reuse the producer’s types.
One value inside those bodies is not closed. A reason says why an answer could not be
given, and the answer itself sits beside it: a document row’s unsupported_reason next to
its status, an unsupported-semantics row’s reason next to its kind. A consumer that
meets a spelling it has never seen still knows the document went unscanned, or the
reference went unevaluated, so those two are open strings inside the major. A reader keeps
a spelling it does not know and writes it back byte for byte, which the payload digest
requires of it. The unsupported-semantics row is one shape for every reason, the reason and
the target it located when it located one, so a new reason needs no new row; the engine
writes a target for query, code-fragment and fragment and for nothing else.
Everything a consumer has to name in order to judge stays closed and still refuses a value
it does not know: every kind, disposition, status, schema name, and compatibility itself.
The missing reasons stay closed as well, since each one selects which evidence its row
carries and each feeds the finding key.
Observation comparisons, occurrences, intents and identity inputs are closed too. An
occurrence is its identity input plus its outcome: observation_id_input carries the
document, adapter, source construct, source projection digest and extracted intent once,
and beside it sit only the observation id, the resolution, the source span, the block kind
and any external destination. A comparison’s sides says what each tree held: same
carries the one occurrence both trees hold, and each carries a base and a candidate
occurrence, either of them null for a side that has none. An each pair whose two
occurrences are equal is not the writer’s spelling and is refused. The
refs command retains the decoded models for querying and output; it does not reparse
individual occurrences or retain unknown extension fields. Candidate and alternative order,
byte-path spelling and canonical JSON output remain unchanged.
Finding-key scopes use the enum’s own tag and reject unknown members, including nested target intents and occurrence identities. Finding keys are hashed through the canonical writer, so Rust field declaration order does not affect identity or adoption reproduction.
Projection sources reuse the policy producer’s model through report decoding. Their tags select closed bodies; a record-set tag cannot hide a record-value selection. A tree-path suffix may be absent or null when decoded directly. Writers omit an absent suffix; report admission rejects an explicit null that would change during typed canonicalization.
Projection differences and debt/waiver diagnostics also use their enum’s own tag to select
a closed body. Diagnostic trees must be objects. A count’s observed_count and a waiver’s
current_fact_digest are emitted even when null. Canonical fact bytes stay unchanged.
Finding facts reject unknown members throughout their evidence and control-state objects. One tagged evidence enum serves both report construction and borrowed adoption preimages; the latter keeps the original resolution borrowed. Site-defect identities use its derived kind label, preserving their kind/route preimages. Embedded document rows and observation comparisons must be objects, not positional arrays.
Finding rows, locations, aggregation, fixes, policy steps and applied debt/waiver metadata reject unknown members. Evaluation carries applied exceptions directly into the report; it retains their provenance without cloning the accepted or authorized fact again. Nullable finding fields remain explicit on output, and canonical report bytes stay unchanged.
The evaluation records candidate_ref and target_ref separately. The candidate ref is the
source branch used for same-repository URL resolution; the target ref is the protected branch
to which branch-scoped controls were matched. Either may be null on a local, self-asserted run,
and the direct CLI currently leaves the target null. Both values enter the candidate-identity
preimage. They describe the exact inputs the engine evaluated; their presence does not prove who
selected or authenticated them.
Candidate identity is serialized from the same typed evaluation carried by the report,
with evaluation_instant and trusted_time excluded and the candidate-identity schema
added. The report reader and sealed acceptance reject unknown evaluation and snapshot
members, as the published schema requires; they never discard extra identity data before
hashing it. Missing clock fields fail the canonical comparison; wrongly typed clock fields
fail decoding. A null
instant or a string that disagrees with the trusted statement fails control verification.
An exact same-repository forge URL carries an optional commit_oid in its target intent and
finding-key projection. The engine resolves it only from that commit’s objects already available in
the declared Git roots. If the commit or any required object is unavailable, an
unsupported-version resolution retains known-commit with the exact ID and contained path; it
does not turn unavailable evidence into a missing target, and the decoded URL is retained for the
provider-evidence layer. Named refs and ambiguous ref/path splits retain the narrower known-path
or unknown-path forms instead of being guessed into a commit.
The sealed commit-pair path, including every provider lane, still reports
explicit-commit-pair and explicit-replay. Provider event and publication facts remain outside
the engine report.
A repository path anywhere in the payload has exactly one spelling. Valid UTF-8 bytes
travel as a plain string; anything else travels as {"bytes": [...]}, the raw bytes as an
array of byte values. A writer never uses the object form for bytes that decode as
text, so every derived digest stays whole.
external_destination holds the URL retained by an external or unavailable historical resolution.
Most such URLs delegate evidence to another layer: an external URL or a same-repository exact
commit whose required local objects are unavailable. The occurrence keeps the URL after the
format’s own decoding so that
https://example.com/x?a=1&b=2 is recorded as the address a fetcher would request rather
than as the bytes the source spells. A locally resolved or disproved historical target has no
delegated destination. An ordinary external URL raises no finding and the summary counts it under
external_out_of_scope, because the engine never fetched it and so decided nothing. The
summary counts every candidate reference twice over: once by what it names, explicit_local,
same_repository or external_out_of_scope, and once by what the run concluded, resolved,
missing, or unsupported for one it declined to judge, whatever it names. A Sphinx
label resolved through candidate-bound inventory evidence carries both the selected destination
and reason: "intersphinx-inventory", but counts as resolved and is not delegated again. A
generated route proved by complete site-build evidence similarly carries reason: "site-build",
counts as resolved, and has no external_destination because it names no external target.
An occurrence whose target misses a heading anchor or a label carries fragment, the text its
fragment_digest digests, so a reader can name what is missing without the tree. It stays
outside the observation identity and every fact, so no key or fact digest moves with it.
The external plan derives the introduced and removed destinations from
a written report, and Amiss and link checkers shows the pipe that hands
them to the tool that does fetch.
Every finding carries its kind, its location with byte offsets, its attribution, the
policy steps that set its final disposition, and the digests of the facts underneath it.
The key_input that produced the finding’s identity is included too, so an external
system can recompute any finding’s identity from the report alone.
A row is one finding key, and the key’s scope decides how much one row covers. A missing or
mistyped target keys on the document, the exact target, and the block holding the reference, so
a link one block repeats is one row, and each block that repeats it has a row of its own.
The four records that answer for a single reference, target-declared-untracked,
unsupported-reference-semantics, unsupported-target-kind, and unsupported-version-scope,
key on the document alone. That makes one row per document and kind: aggregation.member_count
counts its references, observation_ids names each of them, and the location is the first.
Each reference keeps its own resolution and reason on its observation row. An invalid
reference keeps a row of its own, since it carries an attribution. aggregation.locations_omitted
counts the members whose place the row cannot point to: none where observation_ids names every
member, and all but the representative for a claim or control row, which names no observation.
feedback is the smaller review surface derived by the engine from those exact findings.
Related introduced problems become one fix per target, and so do problems whose
attribution is unknown, since enforce-introduced keeps those blocking. Changed targets
under unchanged prose become one check, and pre-existing failures and warnings become one existing per
target after them, so the backlog is listed, not only counted; existing_count stays the
number of those grouped subjects. Each item retains its affected-location count and
contributing finding kinds. A Fix may carry one candidate-side text-path annotation;
Checks and pre-existing items never do. The report retains every item. An incomplete
comparison instead emits exactly {"status":"unavailable"}, so scan failure cannot look
like zero feedback. The evaluation identity follows the same rule: a side that never
resolved to a commit and its tree is {"kind":"unavailable"} with the reasons it did not,
while a side that did keeps its real identity, so a commit ID never stands in for a tree ID.
The envelope, down to its top-level keys:
{
"schema": "amiss/scanner-report-envelope",
"payload": {
"schema": "amiss/scanner-report-payload",
"compatibility": "3",
"engine": { "engine_digest": "sha256:..." },
"evaluation": {},
"controls": {},
"result": { "status": "fail", "complete": true, "exit_code": 1 },
"feedback": { "status": "available", "items": [], "existing_count": 0 },
"summary": {},
"documents": [],
"observations": [],
"findings": [],
"errors": []
},
"payload_digest": "sha256:..."
}
And one finding row from a real failing run, abridged to its skeleton:
{
"kind": "explicit-target-missing",
"description": "a reference names a repository path, a line range inside one, or a heading anchor no known renderer publishes; restore the target or correct the link",
"attribution": "introduced",
"effective_disposition": "fail",
"location": {
"path": "docs/src/introduction.md",
"side": "candidate",
"span": { "start_line": 49, "start_column": 1, "end_line": 49, "end_column": 38,
"start_byte": 2912, "end_byte": 2949 }
},
"finding_key": "sha256:56a75485757d90b5959298c05f6b0531139b016533db320905ee532e5dd42512"
}
Findings are sorted by finding key, a domain-separated hash of kind plus scope. Every
immutable commit identity is part of that scope, so equal paths in two commits remain different
targets for correlation, findings, debt, and waivers. A reference kind with no repository-path
component retains the empty-string sentinel in normalized_target_intent.path; a repository path
uses the ordinary text-or-byte path form. Every
finding and error row carries a description: the fixed engine-owned sentence for its
kind or code, stating what the row means and what to do about it, so no consumer needs a
second source to act on a report. Beside it sits fix, a machine-applicable rewrite or
null, whose own description is one of a closed set of engine-owned sentences named by
FixKind: when the engine can prove the exact edit, the field names the candidate document,
the byte span to replace, and the replacement text, and a finding whose correct content
is not derivable carries null rather than a guess. Three producers emit one today: the broken
value claim carries its definition respelled to expect the target’s current line,
proven by classifying the rewrite back through the claim grammar (see
Claims), and a lone drifted heading anchor carries its fragment
respelled to the one published identity it names apart from case and separator style,
over bytes the adapter located verbatim, and a lone case-drifted path carries its
written path part respelled to the one tracked path it matches apart from case.
amiss fix applies these spans to the staged working tree in
place, refusing any document whose bytes moved since the evaluation. The sentences live in one place,
FindingKind::meaning, AnalysisErrorCode::meaning, and FixKind::meaning;
the lists in Profiles and findings and Analysis errors
and the shipped example are checked against that source in CI. The human format prints
the result plus at most ten grouped feedback items, replaces every character outside printable
ASCII with a \uXXXX escape, and every such byte of a path that is not UTF-8 with \xhh, so a
hostile filename cannot inject terminal control codes or a forged CI command into a log and a raw
byte never reads as the character sharing its value, and states any overflow explicitly. It keeps
raw totals and prints each sentence once as a note line, for every error code and every finding
kind the run reports, while the rows themselves stay in JSON. The places
under a row read by document, then line, then column, so a reader can scan down to one, and
two places at a single position settle on the finding key, which the report holds distinct.
The findings array itself keeps its finding-key order, since that is the identity a consumer
matches across runs and a location order would move a row whenever a line moved. The JSON
is never cut short: a serialized report that would cross the machine-json-bytes ceiling
ends the run incomplete with OUTPUT_LIMIT_EXCEEDED instead of shortening the list, and
the findings count has its own separate ceiling in Limits and refusals.
--format sarif writes exactly one line to stdout: a SARIF 2.1.0 log projected from the
same payload. Every finding row becomes a result under its kind’s rule, fail as error,
warn as warning, and record as note. The message leads with the row’s own words, the
kind, reason and target a human place line prints, and then its description, so two broken
links under one rule read apart; a recorded row, which shows no place, carries the sentence
alone. Each rule carries that sentence as its help, linked to Profiles and findings.
A row carrying a fix projects it as a SARIF fix with the byte region and replacement, and the
finding key rides as a partialFingerprints entry, so a consumer that reads either matches
results across runs by the identity the report uses. GitHub code scanning reads neither, as
CI explains. A row located in the base, such as a reference the change
resolved or removed, is left out: the line it names is gone from the candidate, and a
code-scanning tool closes an alert only when its row stops appearing. A location’s URI is
the path’s own bytes percent-encoded, so a hostile path cannot break it and a name that is not
UTF-8 still names its file. The run declares columnKind as unicodeCodePoints, the unit every
report column counts. Retained analysis errors become tool execution notifications, an
incomplete run reports executionSuccessful false, and a rejected machine invocation
still answers in SARIF with exit class 2. Like the human form, the projection cannot
change facts, ordering, totals, or the exit class; the canonical report stays the only
wire, and consumers that need the full evidence read it there.
--format codequality projects the same payload as GitLab’s Code Quality artifact: a
JSON array with one issue per finding row in report order, leaving out rows located in the
base for the same reason, the row’s own words and then its
description as the issue text, as in SARIF, its kind as check_name, and fail as major, warn as minor, and
record as info. The finding key rides as the fingerprint, so GitLab’s diff of target
against head recognizes the same finding across runs by the identity the report uses.
GitLab requires a path and a first line on every issue, so a byte-named document answers
with the wire’s hex spelling, a finding on no file answers as (global), and a byte-only
span reads as line one. The format has no
shape for analysis errors or a refusal: a rejected invocation answers with a valid empty
artifact, the exit class still carries the truth, and error detail stays on the JSON and
human lanes. The same projection bounds apply.
A render-only --format junit projects one suite for generic CI test dashboards. Findings
become cases under their stable finding keys: fail becomes a failure whose message leads with the
row’s own words as in SARIF, while warn and record
remain passing cases with their exact disposition in system-out. Retained analysis errors become
error cases. A row-free passing report emits one passing report case so the artifact remains
visible. File attributes carry only text that round-trips exactly through XML 1.0, and time is
always zero because the report records no duration. JUnit remains display data, since the
renderer’s exit is still the report’s recorded verdict.
A JSON report can be projected later without repeating repository evaluation:
amiss render --report amiss-report.json --format sarif, --format codequality, or
--format junit emits an alternate deterministic view. SARIF and Code Quality are byte-identical
to their originating check projections; JUnit has no direct-check form. Human is available too;
adding --full to that human replay emits every feedback row rather than the two ten-item windows.
The renderer verifies the active envelope and compatibility, the payload digest, and the recorded
result tuple, then exits with that report’s original verdict. JSON is not a render target because
the input file is already that canonical projection.
The report is evidence of engine evaluation, not a self-authenticating provider attestation. A
control row with status: "verified" means that the engine accepted the supplied digest and
repository, target-ref, tree, time, or run relationships required for that control. A caller that
can supply the request can still make those assertions; the enum does not identify or
authenticate the caller. The sealed bootstrap additionally checks the requested identities and
digests against the returned envelope, but republishes the accepted bytes unchanged. The
controls.semantic_evidence array similarly records each accepted envelope’s payload digest and
producer/input identity; it proves engine binding and interpretation, not who acquired the
inventory.
The provider lanes leave separate provider evidence: an App-owned Check Run on GitHub’s test merge, a protected GitLab policy-job result on a merge-train commit, or a dedicated Gitea-family review. GitHub’s Check Run and the Gitea-family review carry the staged summary and report digest; GitLab’s provider-visible evidence is the exact policy job’s outcome. The controller’s saved result binds the plan, execution constraint, and gate identity in every lane. When a report is present, it also binds the report digest. No provider signs or adds fields to the report. Moving the same report bytes away from that gate therefore loses the provider context; there is still no provider attestation inside the current report contract.
Sandbox provenance is separate again. The present writer reports self-asserted assurance,
local-process enforcement, and null verification. The sealed bootstrap requires that honest
projection. Runtime-closure validation, a cleared environment, fixed input, and a watchdog do
not satisfy the report schema’s provider-verified OCI or microVM mechanisms.
Control provenance retains the concrete execution descriptor, trusted-time statement and semantic producer metadata. Readers reject unknown control and sandbox members, including when the report’s payload digest matches. Sealed acceptance additionally requires verified controls and an explicit evidence list, then checks the retained bodies and their independent bindings. It never reconstructs those bodies from arbitrary JSON maps.
The machine contract is the current report schema, its readable example, and the corresponding canonical bytes. The test suite validates emitted bytes with an independent schema validator, checks the canonical example, and checks that the schema identifiers match the writer constants in the documentation contract test.
The wire is versioned by its own compatibility field, not by the engine release: 3
since the tolerant-reason reshape, additive within the major. A 3 report may gain
optional fields and new reason spellings as 3 rolls forward, and nothing a 3.0 consumer
parsed ever changes meaning or disappears. The promise is mechanical: the frozen example
that opened the major is retained permanently beside the rolling one, a contract test
requires every later schema in the major to keep validating it, and a second test holds the
example the last release shipped to the same bar. Reshaping past that promise mints 4,
and that release is a major one. Every reader of 3 refuses a 2 report: the render and
refs verbs, the external plan, and the controller. The record of how the contract earned
the freeze, and of the reshape that minted 2, is in
A settled wire; the record of 3 is in
A reason may grow.