The Attestr Claim Standard, v0
Status: frozen. Every format in this document is fixed at v0. Changing one changes what already-published signatures mean, so a change needs a new version, not an edit.
This document is the contract between Attestr and anyone who wants to check its
work without asking Attestr anything. Everything here is implemented in
Attestr-dev/claimer, which is open
source, and the shared test vectors in fixtures/vectors/v0.json are what both
the command-line verifier and the in-browser one are held to.
1. What this is for
A company says something about its software. Attestr turns that sentence into a structured claim, collects evidence the company does not control, runs an open-source evaluator, signs the result, and appends it to a public append-only log.
None of that is worth anything unless a stranger can check it. So the formats below are designed around one question: what does a reader need in order to reach the same conclusion without trusting us?
Four rules follow, and the rest of the document is their consequences.
- The claimant is not trusted. Prefer evidence they cannot edit. Every piece of evidence records who supplied it.
- Attestr is not trusted either. Every result is signed and logged. Anyone with the same evidence can re-run the same evaluator and get the same bytes. No Attestr API ever returns a "valid" flag — it returns documents and proofs, and the reader's own machine decides.
- Limits are reported. Missing or partial evidence gives
not_evaluable, neverpass. Every outcome carries its coverage; every claim carries its limitations; statistical claims say so. - attestr.dev holds itself to the same standard.
2. Subjects
A subject is what a claim is about. It has a kind, a name, and a digest map that pins the exact version examined.
{
"kind": "source_tree",
"name": "git+https://github.com/Attestr-dev/claimer",
"digest": { "gitCommit": "…40 hex…", "sha256": "…64 hex…" }
}
| Kind | What it names | Digest |
|---|---|---|
source_tree |
a repository at a commit | gitCommit, and sha256 of the canonical tree |
contest |
a pay-to-vote contest | sha256 of the log checkpoint root the attestation covers |
service, artifact, domain |
reserved | — |
A subject must have at least one digest. Without it a claim would be about a moving target.
subject_id, as it appears in URLs, is the unpadded base64url of the name
(RFC 4648 §5, no =).
2.1 The canonical source-tree digest
Defined so it can be re-derived with coreutils and nothing else. For every file that survives the exclusion rules in §2.2, sorted by its slash-separated relative path in byte order, emit one line:
<lowercase hex sha256 of the file's bytes><space><space><relative path>\n
The digest is the SHA-256 of that stream. The layout is sha256sum's, so:
# equivalent, modulo the exclusion rules
find . -type f | LC_ALL=C sort | xargs -n1 sha256sum | sha256sum
The digest covers every file, not only the ones any evaluator can read — the subject is the tree, not our reading of it.
2.2 Exclusion rules
Fixed, published, and not configurable by the subject. A repository that
could exclude a file from analysis by adding a line to .gitignore could hide a
leak behind a claim that says it has none, so .gitignore is deliberately not
honoured.
| Rule | Excludes |
|---|---|
hidden_path |
any path component beginning with . |
build_directory |
a target directory |
vendor_directory |
a vendor directory |
symlink |
symbolic links, which are never followed |
too_large |
files over 4 MiB |
unreadable |
files that cannot be read as UTF-8 |
Every exclusion appears in the outcome's coverage, with the rule that caused it.
3. Claim definitions
A YAML file at catalog/claims/<pack>/<ID>.yaml. The ID and version together
are the unit of verification — never the prose. Rewording a statement does not
change what the claim means; changing what it means requires a new version.
id: NO_PERSISTENT_STORAGE
version: 1
pack: privacy-code
title: Sensitive values are never logged or written to a database
statement: >
No value wrapped in Sensitive<T> reaches a logging macro or a database write.
subject_kind: source_tree
evidence: [source_tree]
evaluator: privacy-code/no_persistent_storage
determinism: deterministic
params:
languages: [rust]
limitations:
- Only Rust source can be analyzed today. …
The loader refuses a catalog where any claim:
- has an empty
limitationslist — a claim that states no boundaries is implicitly claiming it has none; - sits in a file whose name does not match its
id; - declares a
packthat is not the directory it is in; - names an
evaluatoroutside its own pack.
determinism is deterministic or statistical. A statistical claim is not a
weaker deterministic one; it answers a different kind of question, and every
surface that shows one must label it.
4. Evidence
A named input, pinned by digest, with a record of who supplied it.
{ "kind": "source_tree", "digest": { "sha256": "…" }, "suppliedBy": "attestr" }
suppliedBy is one of:
| Value | Meaning | How much to discount it |
|---|---|---|
attestr |
Attestr collected it — a clone at a commit, a probe we ran | least |
processor |
a third party with no stake, such as a payment processor | some |
claimant |
the company making the claim handed it over | most |
This field is rule 1 made concrete. A reader discounts evidence by where it came from, so it has to say.
5. Outcomes
An evaluator is a pure function of subject, evidence and parameters. No database, no network, no clock. It returns exactly one of three answers.
| Status | Means |
|---|---|
pass |
the evidence was complete, and nothing contradicted the claim |
fail |
something contradicted it; the exceptions say what |
not_evaluable |
there was not enough evidence to say, and reason says why |
There is no fourth, and in particular no "pass with warnings".
Precedence is fixed:
- Any exception is a
fail, even with incomplete coverage. An exception we found is positive proof; not having read everything does not make it go away. - No exception plus incomplete coverage is
not_evaluable. We cannot say that evidence we did not read contains nothing. - Otherwise
pass.
5.1 Coverage
A flat object of counts, whose keys depend on the subject kind. For a source tree:
{ "filesScanned": 142, "filesSkipped": 3, "filesUnparsed": 0, "filesUnsupported": 0 }
Two of those make coverage incomplete, and so block a pass:
filesUnparsed— source we tried to read and could not.filesUnsupported— source in a language the evaluator has no analyzer for.
They are different causes and the same fact: there is code that might contain a
violation and nobody looked at it. filesSkipped is not in that category —
those are excluded by the published rules of §2.2, which say they cannot contain
claimant source.
5.2 Exceptions
{ "locator": "src/lib.rs:8:4", "detail": "`api_key` … passed to `println!`" }
locator is in whatever terms the pack uses — file:line:column for code, a
leaf index or receipt code for a contest. It is never an absolute path.
6. Attestations
An in-toto Statement v1 inside a DSSE envelope, signed Ed25519.
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [{ "name": "git+https://github.com/org/repo",
"digest": { "gitCommit": "…", "sha256": "…" } }],
"predicateType": "https://attestr.dev/claim-result/v0",
"predicate": {
"claim": { "id": "NO_PERSISTENT_STORAGE", "version": 1, "pack": "privacy-code" },
"outcome": { "status": "pass", "exceptionCount": 0,
"coverage": { "filesScanned": 142, "filesSkipped": 3,
"filesUnparsed": 0, "filesUnsupported": 0 } },
"exceptions": [],
"evaluator": { "id": "privacy-code/no_persistent_storage", "version": "0.2.0",
"source": "git+https://github.com/Attestr-dev/claimer@<commit>" },
"evidence": [{ "kind": "source_tree", "digest": { "sha256": "…" },
"suppliedBy": "attestr" }],
"period": { "from": "…", "to": "…" },
"issuedAt": "2026-09-25T00:00:00Z"
}
}
periodis omitted for point-in-time subjects.- Nothing local may appear anywhere: no absolute path, no secret, no personal data, no machine name.
6.1 The envelope
{
"payload": "<standard base64 of the canonical statement>",
"payloadType": "application/vnd.in-toto+json",
"signatures": [{ "keyid": "<8 hex characters>", "sig": "<standard base64>" }]
}
The signature is over the DSSE Pre-Authentication Encoding:
"DSSEv1" SP LEN(payloadType) SP payloadType SP LEN(payload) SP payload
with lengths in ASCII decimal and payload the raw bytes, not the base64.
On base64. DSSE permits standard or URL-safe and requires verifiers to accept either. Attestr accepts either and always emits standard base64 with padding (RFC 4648 §4). The log leaf is the canonical JSON of the envelope, so two encodings of one envelope would hash to two different leaves and the same attestation would appear in the log twice under different names.
6.2 Addressing
An attestation's digest is the SHA-256 of its leaf bytes (§7.1), lowercase hex.
That is what /v0/attestations/{digest} takes.
7. The transparency log
RFC 9162 hashing, C2SP signed-note signatures, C2SP tlog-checkpoint checkpoints, C2SP tlog-tiles serving.
7.1 Leaves and hashing
leaf hash = SHA-256(0x00 ‖ leaf bytes)
node hash = SHA-256(0x01 ‖ left ‖ right)
The prefixes are domain separation: without them a leaf whose contents happened to be two concatenated hashes would be indistinguishable from an interior node.
An attestation's leaf bytes are the RFC 8785 canonical JSON of its DSSE envelope.
7.2 Log origins
An origin names a log identity. It is signed into every checkpoint, and it never changes.
| Log | Origin |
|---|---|
| the global attestation log | attestr.dev/log/attestations |
| a contest's event log | attestr.dev/log/c/<contest_id> |
Where the bytes are served is a separate question. Today they are served
from log.attestr.dev, with the attestr.dev/log/ prefix stripped — so
attestr.dev/log/attestations is at https://log.attestr.dev/attestations/….
That separation is deliberate: it is what lets anyone mirror the log at their own
address without a single signature breaking.
7.3 Checkpoints
attestr.dev/log/attestations
142
XcnaeacGWamtVZy3Ad7Zoqudgjqt9JYM/jcO7/YEQyg=
— attestr.dev 03f7HNaLMEXSPqDBNq5A8Z0…
Origin, tree size in decimal with no leading zeroes, standard base64 root hash, optional non-empty extension lines, a blank line, and one or more signature lines. The em dash is U+2014.
A signature line is — <key name> <base64(key id ‖ signature)>, where the key
id is four bytes:
key id = SHA-256(name ‖ 0x0A ‖ 0x01 ‖ public key)[:4]
0x01 is Ed25519 and the public key is its 32 raw RFC 8032 bytes. The same
derivation produces the keyid in a DSSE envelope, so the browser and the
command line name one key the same way.
A verifier must ignore signatures from keys it does not know, and must reject the note if no signature from a known key verifies.
Checkpoints publish every checkpoint_interval seconds when the tree has grown —
60 seconds by default, 2 in the sandbox — and every published checkpoint is
kept forever.
7.4 Proofs
Inclusion and consistency proofs follow RFC 9162 §2.1.3 and §2.1.4 exactly, for both generation and verification.
A note on consistency. Verification takes two sizes and two roots and establishes a relationship between them. It cannot tell you that a root really belongs to a tree of the stated size, because nothing in a bare root says how many leaves produced it — and for some size pairs a true root under a slightly wrong size still verifies. The binding between a size and a root is the checkpoint signature. A verifier must take both from a checkpoint it has already verified, never from an untrusted parameter.
7.5 Tiles
Height 8: a full tile is 256 hashes, 8,192 bytes, served as raw concatenated
hashes. Tile level L holds node level 8·L.
<prefix>/checkpoint
<prefix>/tile/<L>/<index path> full
<prefix>/tile/<L>/<index path>.p/<W> partial, 1 ≤ W ≤ 255
<prefix>/tile/entries/<index path> entry bundles
The index path is zero-padded three-digit groups, all but the last prefixed x:
index 1234067 is x001/x234/067. Entry bundles are big-endian uint16
length-prefixed entries.
A partial tile is never hashed into the level above, so full tiles are immutable once written. That is what makes the log a bucket of static files anyone can mirror.
8. Canonical JSON
RFC 8785, with one restriction.
Objects, arrays, strings, booleans and null follow the RFC exactly, including sorting object keys by UTF-16 code units — which differs from code-point order above the basic plane, and is the detail most implementations get wrong.
Numbers must be integers in [-(2^53-1), 2^53-1]. Anything else is an error.
RFC 8785 defers number formatting to ECMAScript's Number::toString, which the
RFC itself declines to specify. Nothing Attestr canonicalizes contains a
non-integer — sizes, indexes, counts, units, versions and minor-unit amounts are
integers, and timestamps are strings — so rather than risk a serialization that
differs between implementations, a non-integer stops the canonicalizer. A
canonicalizer that silently produces a result for input it cannot handle
canonically is worse than one that refuses.
9. Receipts
Issued to whoever created an entry, once a checkpoint covers it.
{
"v": 0,
"origin": "attestr.dev/log/c/01J2X",
"leaf": "9f86d081…",
"index": 417,
"checkpoint": "attestr.dev/log/c/01J2X\n512\n…\n\n— attestr.dev …\n",
"inclusion": ["…", "…"]
}
A receipt is self-contained: it carries the checkpoint it was issued against, so a holder can verify it offline, against a copy of the public key, years later. Later they can check that the log they are shown today is consistent with the checkpoint in their hand — and that is the check that catches a log rewriting its history.
The short code is the first eight hex characters of the leaf hash, formatted
XXXX-XXXX. It is a lookup key. It proves nothing, authorises nothing, and two
entries could in principle share one — so looking one up returns candidates.
10. Commitments
Used by the count-integrity pack so that raw identities and processor
identifiers are never stored.
contest_key = HKDF-SHA256(ATTESTR_MASTER_KEY, salt="attestr/v0/contest-key", info=contest_id)
commit(x) = lowercase_hex(HMAC-SHA256(contest_key, x))
The contest key is derived, never stored, and given to the operator exactly once when the contest is created.
11. Packs
A pack is one kind of evidence and one family of evaluators.
| Pack | Subject | Status |
|---|---|---|
privacy-code |
source_tree |
available |
count-integrity |
contest |
available |
build-provenance |
artifact |
planned |
outside-in |
domain |
planned |
data-retention |
service |
planned |
ai-data-use |
service |
planned |
participant-survey |
contest |
considered, not planned |
11.1 One claim, many languages
A claim is about behaviour, not about a language. There is one claim per
behaviour and one analyzer per language behind it — never
NO_PERSISTENT_STORAGE_SWIFT.
Source in a language with no analyzer is a coverage hole. It makes the outcome
not_evaluable, and the reason names the languages and counts. Scoping the claim
to one language and passing would produce a green result that quietly means "in
the Rust parts", which is exactly the technically-true statement this standard
exists to replace.
11.2 Planned packs
build-provenance— SLSA/in-toto provenance and reproducible builds. This closes the gapprivacy-codenames in its own limitations: source at a commit is not the binary you run.outside-in— third-party trackers, cookies, TLS and security headers, from probes Attestr runs itself. Evidence nobody else supplies.data-retention— deletion events and storage audits.ai-data-use— what a model was trained on and what it retains.participant-survey— considered and set aside. Survey responses are self-reported, respondents are self-selected, and paying per response rewards answering rather than answering honestly. If it is ever built it will be its own pack, markeddeterminism: statistical, and kept away from the count claims — mixing a weak claim into a set of strong ones does not raise the weak one, it lowers all of them.
12. What this standard does not give you
Stated plainly, because a standard that hides its limits is worth as much as a claim that does.
- A single-operator log is not tamper-evident against a split view. Attestr
could in principle show two readers two different logs. Consistency proofs
catch it only if the two readers compare notes. Witness co-signing is designed
for and not yet built;
Notealready accepts multiple signatures. - There is no key history document. The verifier pins one
attestr.devkey. Rotation and revocation need designing before any key signs for a real customer. - Evaluators are named, not proved. A claim says a function called
redactwas called. It does not say the function redacts anything. - Source is not the binary. Everything in
privacy-codedescribes code at a commit. - Anchoring is planned. Daily checkpoint anchoring to EAS on Base is stubbed. Until it ships, the log's history rests on readers keeping their checkpoints.
13. Checking it yourself
git clone https://github.com/Attestr-dev/claimer
cd claimer
cargo run -p claimer -- verify attestation ./attestation.json --key ./attestr.dev.pub
cargo run -p claimer -- verify receipt ./receipt.json --key ./attestr.dev.pub
cargo run -p claimer -- verify consistency --older old.txt --newer new.txt \
--proof proof.txt --origin attestr.dev/log/attestations
The same code compiled to WebAssembly is what runs on attestr.dev. Both are held
to fixtures/vectors/v0.json, which contains valid cases and tampered ones:
a flipped signature byte, a forged inclusion proof, a forked log, a rewritten
outcome. A verifier that accepts any of those is broken, and the test suite says
so in both languages.
If the browser and the command line ever disagree, that is a bug, and we want to hear about it.