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.

  1. The claimant is not trusted. Prefer evidence they cannot edit. Every piece of evidence records who supplied it.
  2. 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.
  3. Limits are reported. Missing or partial evidence gives not_evaluable, never pass. Every outcome carries its coverage; every claim carries its limitations; statistical claims say so.
  4. 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 limitations list — 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 pack that is not the directory it is in;
  • names an evaluator outside 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"
  }
}
  • period is 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 gap privacy-code names 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, marked determinism: 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; Note already accepts multiple signatures.
  • There is no key history document. The verifier pins one attestr.dev key. Rotation and revocation need designing before any key signs for a real customer.
  • Evaluators are named, not proved. A claim says a function called redact was called. It does not say the function redacts anything.
  • Source is not the binary. Everything in privacy-code describes 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.