# Autonomy Trust Envelope Protocol (ATEP): Specification, Draft 07 Oct 2, 2026 · AIRAD LABS ## 1. Abstract and status ATEP (Autonomy Trust Envelope Protocol) is a transport-agnostic trust layer: a signed and encrypted envelope that lets one autonomous system, a robot or an AI agent, prove to another who produced a piece of data, that it was not altered, and what certifications the producer holds. It rides on top of any carrier (MCP, A2A, HTTP, message queues, files on disk) and replaces none of them. ATEP defines four things: an identity model for agents, a COSE/CBOR envelope format, an attestation schema for third-party certifications, and a revocation plus transparency-log mechanism that makes certifiers accountable. ATEP is quantum-safe by design: every signature and key exchange is hybrid, pairing a proven classical algorithm with a NIST post-quantum standard (ML-DSA, FIPS 204, and ML-KEM, FIPS 203) from the first version, so data signed or encrypted today is designed to remain protected against future quantum computers for as long as either half of the hybrid construction holds. "Quantum-safe" here means the finalized NIST post-quantum standards in a hybrid construction; it does not mean quantum-proof, and the specification and reference code have not been independently audited. Two properties are worth stating plainly, with their limits. *ATEP is quantum-safe, and it has not been independently audited.* Every signature is a hybrid pair, Ed25519 with ML-DSA-65 (FIPS 204), and every key exchange is a hybrid pair, X25519 with ML-KEM-768 (FIPS 203); both halves must verify or hold, so the protocol stays secure if only the classical half or only the post-quantum half is broken. Payload encryption is AES-256-GCM and hashing is SHA-256. "Quantum-safe" is a statement about the choice of the finalized NIST standards and about how they are combined; it is not a proof, and neither this specification nor the reference code has had an independent security audit (section 13). *Robots and agents can identify and verify each other offline.* An identity is the SHA-256 hash of its public keys (section 4), so an Agent ID names its keys and needs no registry, directory or certificate authority to be resolved, and the sender can carry its public-key bundle inside the envelope. Steps 1 to 8 of the verification algorithm (section 10) need no network at all. Step 9, which decides whether the sender holds the certifications the receiver requires, needs only cached issuer keys, signed revocation lists (SRLs) and log checkpoints. The honest condition is that those caches must have been filled earlier, while there was a connection, and a stale SRL fails closed: for the robot command classes that move or actuate (`motion`, `actuation`, `maintenance` and `safety` commands other than an e-stop) a missing or stale list rejects the command, and under the default policy of any verifier a stale list rejects the attestation it covers (sections 3, 8 and 17). ATEP does not define transport, a discovery protocol, task negotiation, or the meaning of the payload it wraps. It does define two discovery aids: the domain records with which a domain authorizes Agent IDs (section 4) and the `registry-endpoint` claim that binds a service URL to an Agent ID (section 7). Status: Draft 07, first public draft. Drafts 00 to 06 were internal working drafts that were not published; the numbering continues so that references in the vectors and in the implementation notes remain meaningful (Appendix A). Draft 07 carries the technical content of Draft 06 and changes no wire format, no error code, no vector and no normative rule. It is a working design document for review. The specification was written together with the reference implementations: it resolves the 7 issues (50 to 56) that the Rust implementation found while building the anchoring and discovery vectors and the 13 reports (32 to 44) of the independent Python implementation, which was built from an earlier draft, the vector notes and the vectors only, and wherever an earlier text or `vectors/README.md` said something other than the vectors, the vectors win and the text says what they say (docs/implementation-findings/rust-findings.md and docs/implementation-findings/python-findings.md hold the findings). The suite has 436 vectors in 28 categories, in `vectors/`. The Rust implementation passes all 436. The npm package (the Rust core compiled to WebAssembly) and the Python implementation each pass 431 and report 5 as skipped by name, the 4 `log-admission` vectors and the 1 `monitor` vector, because they have no stateful log or monitor. This draft states the domain records, the limits of the TXT record and of the well-known document, the `registry-endpoint` data rules, the `require_anchor` values, the `chain-id` text rules and the anchor record integers to the level of the vectors, and keeps open, with the exact behavior of the implementations stated, what no vector decides: an encrypted anchor, a 1,025th agent entry, a 17th TXT record, the public suffix check, the `require_dnssec` option, the evaluation of anchors against a witness and the fork alert (sections 12 and 14). The repository is public: github.com/atepdev/atep, under the GitHub organization `atepdev`. Package names are reserved but no functional package is published (section 13). Intended to become an Internet-Draft once the open items of section 13 are closed. Header labels and media types in this draft are provisional (section 5) and are registered with IANA when the Internet-Draft is submitted. The key words MUST, SHOULD and MAY are used as in RFC 2119. ## 2. Terminology | Term | Meaning | | --- | --- | | Agent | Any software actor that produces or consumes ATEP envelopes: an LLM agent, a tool, a service, a human-operated client. | | Identity | A hybrid keypair (classical + post-quantum) and the Agent ID derived from its public keys. | | Agent ID | A 32-byte SHA-256 digest of the canonical public-key bundle, shown as `atep:` plus base32. Stable for the life of the keys. | | Envelope | A COSE\_Sign (multi-signature) object carrying a payload, protected headers and one hybrid signature set. Optionally wrapped in COSE\_Encrypt. | | Payload | Arbitrary bytes the envelope protects. ATEP does not interpret them. | | Issuer | An identity that signs attestations about other identities. A certification body, an auditor, a domain owner, or an agent. | | Verifier | The party checking an envelope: validates signatures, attestations, revocation status and its own trust policy. | | Attestation | A signed envelope whose payload is a claim about a subject identity, with issuer, validity window and evidence hash. | | Claim type | A URI naming what an attestation asserts, e.g. `https://atep.dev/claims/domain-control`. Open vocabulary. | | Trust policy | The verifier's local rule: which issuers and claim types it requires before accepting a subject. | | Revocation list | A signed, timestamped list of attestation IDs an issuer has withdrawn, published at a well-known URL. | | Transparency log | An append-only, Merkle-tree-backed public record of every attestation and revocation submitted by issuers. | | Root issuer | An issuer a verifier trusts a priori; the top of an attestation chain. | | Trust document | An attestation, a signed revocation list, a log checkpoint or a log anchor record: the only envelopes that may travel without encryption. | | Checkpoint | A log's signed statement of its tree size, root hash and timestamp. | | Checkpoint hash | `SHA-256` of a checkpoint's payload bytes; the value an anchor commits to (section 9). | | Anchor record | Optional evidence, signed by a log, that a checkpoint hash was written to an external witness (section 9). | | Witness | An external place a log may write a checkpoint hash to: a public blockchain, a timestamping service or a public append-only log. Never a source of trust. | | Submitted form | An envelope without its `-70012` inclusion proof entry; the bytes a log hashes into a leaf. | | Consistency proof | Evidence that a later log tree extends an earlier one. | | Split view | Two valid signed checkpoints of one log that cannot belong to one append-only history. | | Monitor | A party that follows a log and alerts on anomalies. | | Attestation pool | The untrusted attestations a verifier draws on at step 9 (inline, nested and locally stored). | | Local attestation store | The verifier's own cache of encoded attestation envelopes: the third source of the pool, and the place where step 8 looks for `retired` attestations (section 7). | ## 3. Design goals and scope ATEP solves one problem completely: verifiable provenance and certification for data exchanged between agents. Everything else is left to existing protocols. 1. **Trust layer only.** In scope: identity, envelope, attestations, revocation, transparency log. Out of scope: transport, a discovery protocol (domain records and the `registry-endpoint` claim are aids for finding identities and services, not a protocol for finding agents), negotiation, payload semantics. 2. **Transport-agnostic.** An envelope is a byte string. It MUST verify identically whether it arrived over MCP, A2A, HTTP, a queue, or a file. 3. **Quantum-safe by default.** Every signature pairs a classical and a NIST post-quantum algorithm (Ed25519 with ML-DSA-65, FIPS 204); both MUST verify. Encryption is REQUIRED for every envelope exchanged between agents, using a hybrid KEM (X25519 with ML-KEM-768, FIPS 203); only public trust documents (attestations, revocation lists, log checkpoints, log anchor records) are signed without encryption. This is a property of the algorithms and of how they are combined, not a proof, and the protocol and its implementations have not been independently audited (section 13). 4. **Crypto-agile.** Every key, signature and ciphertext carries an algorithm identifier. Suites can be added or retired without changing the envelope structure. 5. **Open adoption.** Any agent can generate an identity and sign envelopes with no permission from anyone. Trust is added by attestations, not gated by registration. 6. **Open claim vocabulary.** Anyone may define a claim type. Verifiers decide which issuers and claims they require. 7. **Accountable issuers.** Issuance and revocation are logged publicly and verifiably. No issuer can act in secret or rewrite history. 8. **Offline-verifiable.** An identity is the hash of its public keys, so identifying a sender needs no lookup; steps 1 to 8 of verification need no network; step 9 needs only cached issuer keys, revocation lists and log checkpoints. A verifier with those caches MUST be able to verify without network access. The condition is that the caches were filled earlier: a revocation list that has gone stale fails closed under the default policy and always for the ATEP-R classes that move or actuate a robot (sections 8 and 17), so offline operation lasts only as long as the lists are fresh. 9. **Standards-based.** Built on COSE (RFC 9052), CBOR (RFC 8949), FIPS 203/204 and existing IETF work on hybrid and PQ COSE algorithms. No custom cryptography. ## 4. Identity An agent's identity is its keypair; no registration is required to exist. Trust is layered on afterwards through attestations. **Key bundle.** An identity holds two signing keypairs that are always used together: one Ed25519 and one ML-DSA-65. An identity MAY also hold a hybrid encryption keypair (X25519 + ML-KEM-768) for receiving encrypted envelopes. Each public key is encoded as a COSE\_Key with its `alg` set, using exactly the encodings below. | Key | COSE\_Key (integer labels) | | --- | --- | | Ed25519 | `{1: 1, 3: -8, -1: 6, -2: x}`: kty OKP, alg EdDSA, crv Ed25519, `x` 32 bytes | | ML-DSA-65 | `{1: 7, 3: -49, -1: pub}`: kty AKP, alg ML-DSA-65, `pub` 1,952 bytes (provisional values) | | X25519 | `{1: 1, 3: -25, -1: 4, -2: x}`: kty OKP, alg ECDH-ES+HKDF-256, crv X25519, `x` 32 bytes | | ML-KEM-768 | `{1: 7, 3: -70010, -1: pub}`: kty AKP, provisional private-use alg, `pub` 1,184 bytes | Kty `7` (AKP) and alg `-49` follow draft-ietf-cose-dilithium and are provisional until assigned. **Agent ID.** The public-key bundle is serialized as a canonical CBOR array `[sig_classical_key, sig_pq_key, enc_keys?]` (deterministic encoding per RFC 8949 section 4.2.1), where `enc_keys` is the two-element array `[x25519_key, mlkem768_key]`. When the identity has no encryption keys the third element is omitted entirely, never encoded as `null` or an empty array. A verifier MUST reject a bundle that is not in exactly this form. The Agent ID is `SHA-256(bundle)`. Its text form is `atep:` followed by unpadded lowercase base32 of the 32 bytes. Example: `atep:k7h2mqv3...`, which is `atep:` followed by 52 base32 characters. A parser accepts `atep:` or `did:atep:` followed by exactly 52 characters of lowercase RFC 4648 base32 that decode to 32 bytes and re-encode to the same text, and nothing else. The ID commits to both keys, so neither half can be swapped without changing the ID. **DID alias.** The same 32 bytes MAY be written as `did:atep:` followed by the identical base32 string, so DID tooling can refer to an Agent ID. The two forms are exact aliases: the `atep:` form is canonical, a verifier MUST treat them as the same identity, and the alias adds no resolution requirements to the core protocol. **Domain records.** A domain authorizes Agent IDs in a JSON document, in DNS TXT records, or in both. The records are public, carry no secrets and are checked by an issuer before it issues `domain-control` (section 7, "Checking a domain binding"). They describe exactly the DNS name they are published under: a record at `example.com` says nothing about `a.example.com`, and a record at `a.example.com` says nothing about `example.com`. There is no inheritance, no wildcard expansion by the checker and no search up the tree. *Well-known document.* `https:///.well-known/atep.json`, fetched with HTTP `GET`. The response MUST be `200`, content type `application/json` (parameters such as `charset=utf-8` are allowed), at most 65,536 bytes, and a JSON object (RFC 8259) with these members: | Member | Type | Meaning | | --- | --- | --- | | `version` | integer | REQUIRED. The integer `1`, written as the JSON number token `1` (not `1.0`, `1e0` or the text `"1"`). A document with another version, or with none, is invalid | | `agents` | array | REQUIRED. The authorized Agent IDs, `atep:` or `did:atep:` text (section 4). At most 1,024 entries are read; entries after the 1,024th are ignored and the document stays valid. An entry that is not text, or is text that is not an Agent ID, is ignored | | `domain` | text | OPTIONAL. The DNS name the document is for. When present it MUST equal, byte for byte, the name it was fetched for, which stops a document being copied to another host; a `domain` that is not text, or differs in case, is invalid | | `srl-url` | text | OPTIONAL. An `https` URL where an issuer on this domain publishes its SRL. It defaults to `https:///.well-known/atep-revocations.cbor` (section 8) and is informational: a value of the wrong type or form is ignored and never makes the document invalid | | `updated` | integer | OPTIONAL. Unix seconds at which the owner last changed the document. Informational, ignored in the same way | Members that are not listed are ignored, so later versions can add members. A document of another `version` is not read: it is *invalid*, not absent (the two differ only in the label of the source, see "Which record counts" below). Example: ```json { "version": 1, "domain": "example.com", "agents": ["atep:k7h2mqv3..."], "srl-url": "https://example.com/.well-known/atep-revocations.cbor", "updated": 1790000000 } ``` The document is fetched over HTTPS only, with full certificate validation for exactly `` and the default port. A fetcher MAY follow at most three redirects, each of which MUST stay on `https` and on the host ``; any other redirect, a response from another host and any `http` response is a failed fetch of an invalid document. Cookies, credentials and a `Referer` are not sent. A status of `404` or `410` means the document does not exist; `429` and `5xx` mean it could not be read; any other status (a `3xx` that was not followed, `204`, `403`) makes the document invalid. *How the checker applies these rules.* The checker sees one answer: a status, the URL the body finally came from (`final_url`), the content type and the body. It tests the answer in this order, and the first failure decides the state. (1) The status, as above. (2) `final_url` MUST begin, byte for byte and case sensitively, with `https:///`, so a different host, a subdomain, a longer host that begins with the name, `http`, an explicit port, userinfo, and a URL with no path after the host are each an invalid document, while any path on the host is fine; the limit of three redirects is the fetcher's duty and cannot be seen by the checker. (3) The content type is the text of the `Content-Type` value before the first `;`, with surrounding white space removed, and is compared with `application/json` without regard to ASCII case (so `Application/JSON; charset=utf-8` passes); a missing content type, `text/plain`, `text/html` and `application/ld+json` are invalid. (4) The body is at most 65,536 octets (a body of exactly 65,536 is read; one of 65,537 is invalid and its content is not looked at). (5) The body is UTF-8 and parses as JSON (RFC 8259), and is an object: text that is not UTF-8, a byte order mark, comments, `NaN`, `Infinity` and `-Infinity` are not JSON and make the document invalid, and a member name that occurs twice takes its last value (RFC 8259 leaves this open; both implementations do it and no vector covers it, so a later draft may make such a document invalid). (6) The members of the table are checked, and a failure of any of them is invalid. *Agent ID comparison.* An entry of `agents` or an `id=` term lists the Agent ID asked about when it parses as an Agent ID (the strict rule of this section: `atep:` or `did:atep:` and exactly 52 canonical lowercase base32 characters) and its 32 decoded bytes equal those of the Agent ID asked about. A `did:atep:` entry therefore lists an `atep:` question and the reverse, and a text that is not canonical (uppercase, another length, padding, surrounding space) is not an Agent ID and is ignored. If the Agent ID asked about does not itself parse, nothing lists it. *DNS record.* One or more TXT records at `_atep.`. The text of a record is its character-strings concatenated with nothing between them, in US-ASCII, at most 1,024 octets, and SHOULD fit in one character-string (255 octets). A record whose concatenated text is longer than 1,024 octets, or contains an octet that is not US-ASCII, is ignored, exactly like a record that does not begin with `v=atep1`: it is not invalid and it does not make the source invalid, and when no record counts the source is absent. Its syntax: ``` record = "v=atep1" *( 1*SP term ) term = id-term / srl-term / ext-term id-term = "id=" agent-id ; atep: or did:atep: text, section 4 srl-term = "srl=" https-url ; at most 2,048 characters, no whitespace ext-term = 1*( %x21-3C / %x3E-7E ) "=" *( %x21-7E ) ; ignored ``` A TXT record at that name that does not begin with the term `v=atep1` is ignored, so the name can carry other uses. The record begins with the term when its text starts with `v=atep1` and the next character, if any, is a space: a leading space, `v=atep10`, `v=atep` and `v=atep1` as a second term do not count. Terms are separated by one or more spaces. Several records are allowed and the authorized set is the union of the `id=` terms of every record that counts; a reader MUST read at least the first 16 records and MAY ignore the rest, so a domain owner SHOULD publish at most 16 records at one name (the Rust implementation reads the first 16 and ignores the rest, the Python implementation reads every record it is given, and no vector places the listing in a 17th record: section 12, known gap 20). A term it does not understand is ignored, and so is an `id=` term whose text is not an Agent ID. A `v=atep1` record with no `id=` term counts and authorizes nobody. Three Agent IDs fit in one 255 octet string: ``` _atep.example.com. 3600 IN TXT "v=atep1 id=atep:k7h2mqv3... id=atep:q4x9..." ``` *Which record counts.* The Agent ID is authorized when at least one source lists it and no source contradicts it. A source that exists, is valid and does not list the Agent ID contradicts a source that lists it: a domain owner who removed the Agent ID from one place has withdrawn it, so a stale record elsewhere does not keep it bound. A source that does not exist (`404`, no `_atep` TXT data, or TXT data of which no record counts) is silent, not contradictory, and so is an invalid source: an invalid source never lists the Agent ID and never contradicts a listing. An issuer MAY require both sources to list the Agent ID, and then the result is the one defined under "Checking a domain binding" (section 7). **Lifecycle.** 1. Generate: the agent creates its key bundle locally and derives its Agent ID. It can sign immediately. 2. Publish: the agent makes its public bundle available, in the envelope's unprotected header (self-describing) and/or via the registry. 3. Bind: optionally, a domain owner issues a `domain-control` attestation linking the Agent ID to a DNS name (`data` is `{"domain": }`, section 7). The issuer first checks that the domain itself publishes the Agent ID in one of the two domain records defined under "Domain records" above. 4. Rotate: a new bundle is created and the old identity signs a `successor` attestation naming the new Agent ID (section 7). A verifier that is configured to follow succession accepts the claims held by the old identity for the new one, for exactly one hop (section 7). The `successor` attestation MUST be issued before the old identity retires or is revoked, because afterwards nothing the old identity signs verifies (step 8). 5. Retire: the identity issues a self-signed `retired` attestation (section 7); afterwards any envelope it signs with an `issued-at` at or after the retirement MUST be rejected by a verifier that knows of the retirement (verification step 8, and "How a verifier learns of a retirement" in section 7). **Private keys** never leave the agent. Key storage, HSM use and key derivation are implementation matters outside this spec, but the Rust reference implementation zeroizes keys on drop; OS keychain and HSM custody are not implemented yet (section 13). ## 5. Envelope format An ATEP envelope is a COSE\_Sign structure (RFC 9052 section 4.1) tagged with CBOR tag 98, carrying exactly one hybrid signature set and an ATEP content-type marker. Data envelopes exchanged between agents MUST be encrypted: the signed envelope is wrapped inside COSE\_Encrypt (tag 96), sign-then-encrypt, so the signature is hidden from observers and verifiable by the recipient. Signed-only envelopes (tag 98 alone) are permitted solely for public trust documents: attestations, revocation lists, log checkpoints and log anchor records, which must be readable by everyone. A verifier MUST reject an unencrypted envelope whose content type is not one of those. **Encryption rule.** Whether an envelope may travel unencrypted is decided by header 3 (content type) alone: only `application/atep-attestation+cbor`, `application/atep-srl+cbor`, `application/atep-checkpoint+cbor` and `application/atep-anchor+cbor` (new in Draft 04) may appear in a bare tag 98 envelope, and a verifier applies this at step 1 (section 10). An implementation written to Draft 03 rejects the anchor type at step 1 as `unencrypted_non_trust_document`; the three implementations of this suite accept it (the `anchor-media-type` vectors), and a look-alike type such as `application/atep-anchor+json` is not one of the four and is rejected. The rule looks at bare tag 98 envelopes only: a tag 96 envelope may carry any content type, one of the four included, and step 1 accepts it as a data envelope. A component that takes a document as published (a checkpoint, an SRL, an anchor record, or an attestation used as a step 9 candidate) verifies it with no recipient identity, so an encrypted one is rejected at step 2 (`no_recipient_key`); for anchors this is stated in section 9, "Checking a published anchor". Because the rule is label based, a sender can label any payload with a trust document type. The label therefore only authorizes sending without encryption; it does not make the payload a trust document. Any component that interprets the payload as an attestation, SRL, checkpoint or anchor record MUST validate it against the schema for that media type (sections 7 to 9) and MUST reject it if it does not parse, and a verifier that evaluates policy at step 9 does so as part of attestation handling. The core verifier of steps 1 to 8 checks the label only, so a malformed payload under a trust document label is not by itself a step 1 rejection, and with no trust policy a bare envelope under such a label verifies whatever its payload is. **Protected header (signed, integrity-protected):** | Label | Name | Value | | --- | --- | --- | | 3 | content type | `application/atep+cbor` (payload is CBOR) or the payload's own media type; the four trust document types are listed below | | -70001 | atep-version | integer, `1` | | -70002 | signer | Agent ID (32 bytes) | | -70003 | issued-at | integer, seconds since Unix epoch | | -70004 | expires-at | integer, optional for data envelopes, REQUIRED for attestations | | -70005 | nonce | 16 random bytes, replay protection | | -70006 | payload-digest | SHA-256 of the payload (allows detached payloads) | | -70007 | suite | text, the cryptographic suite name, `ATEP-1` in this version | | -70014 | command-class | text, one of the ATEP-R command classes (section 17); optional in the core, REQUIRED in ATEP-R envelopes | **Provisional labels.** The labels, algorithm identifiers and media types in this section are private-use values chosen so that test vectors and interoperable implementations can exist before registration. The complete set used by `ATEP-1` is: | Kind | Value | Meaning | | --- | --- | --- | | Header label (protected) | -70001 to -70007 | atep-version, signer, issued-at, expires-at, nonce, payload-digest, suite | | Header label (protected) | -70014 | command-class (ATEP-R, section 17) | | Header label (unprotected) | -70008 | signer-bundle | | Header label (unprotected) | -70009 | attestations | | Header label (unprotected) | -70012 | inclusion-proof | | Header label (recipient, unprotected) | -70013 | kem-ciphertext (ML-KEM-768) | | COSE alg | -49 | ML-DSA-65 (follows draft-ietf-cose-dilithium) | | COSE alg | -70010 | ML-KEM-768 key algorithm | | COSE alg | -70011 | ATEP-1 hybrid KEM recipient algorithm | | COSE kty | 7 | AKP (follows draft-ietf-cose-dilithium) | | Media type | `application/atep+cbor` | data envelope payload is CBOR | | Media type | `application/atep-attestation+cbor` | attestation (section 7) | | Media type | `application/atep-srl+cbor` | signed revocation list (section 8) | | Media type | `application/atep-checkpoint+cbor` | log checkpoint (section 9) | | Media type | `application/atep-anchor+cbor` | log anchor record (section 9), a trust document; new in Draft 04 | Header labels and algorithm identifiers live in separate COSE namespaces, so -70010 and -70011 as algorithm values do not collide with header labels of the same number; label -70010 is unused as a header label. Receivers ignore protected and unprotected header labels they do not know, but the protected header is signed, so unknown protected labels still change the signature input. ATEP requests IANA assignment of header labels, media types and a dedicated CBOR tag when the Internet-Draft is submitted, and a later draft will map each provisional value to its assigned one. Until then the version field governs interpretation. **Suite binding.** The `suite` field is inside the signed protected header, so a verifier knows exactly which algorithms the signer intended and an attacker cannot substitute a weaker suite (section 11). A verifier MUST reject an envelope whose `suite` it does not support or whose algorithm identifiers do not match the named suite. **Signatures array.** Exactly two COSE\_Signature entries, each with its own protected header `{1: alg, 4: kid}` (alg `-8` for `EdDSA`, `-49` for `ML-DSA-65`), one of each. The `kid` of both entries is the signer's 32-byte Agent ID, and a verifier MUST reject a signature whose `kid` differs from the `signer` header (step 3). Signers emit the `EdDSA` entry first and the `ML-DSA-65` entry second; verifiers accept either order, but the shape check (exactly two entries, exactly one of each algorithm, no extras) is part of step 1 as "algorithm identifiers match the suite". Signers emit the empty map as each entry's own unprotected header; verifiers require a map there and ignore its contents. **Sig\_structure and signing modes.** Each signature is computed over its own RFC 9052 section 4.4 Sig\_structure, the encoded CBOR array `["Signature", body_protected, sign_protected, h'', payload]`, where `body_protected` and `sign_protected` are the serialized protected headers as byte strings, `h''` is the empty external\_aad, and `payload` is the actual payload bytes even when the envelope payload is detached. The two signatures differ only in `sign_protected`. Ed25519 is pure EdDSA (RFC 8032) over that encoding, and a verifier MUST verify in strict mode: S MUST be canonical (below the group order L), the public key A and the signature point R MUST both decode and MUST NOT have small order, and the point recomputed from the verification equation, once encoded, MUST equal the R bytes of the signature (so a non-canonical encoding of R fails). ML-DSA-65 is pure ML-DSA (FIPS 204 Algorithm 2 for signing and Algorithm 3 for verification, not HashML-DSA) with the empty context string, over the same encoding. Signing MAY be hedged or deterministic, since both verify identically; the test vectors use the deterministic variant (rnd = 32 zero bytes). A verifier MUST reject the envelope if either signature fails. Hybrid-composite algorithm identifiers (currently in IETF draft for COSE) are not used in `ATEP-1`. Once assigned and stable they will be introduced as a separate suite, `ATEP-2`, so the two-entry form and its test vectors stay valid unchanged. **Unprotected header.** A map that is empty or carries any of these provisional labels. `-70008` signer-bundle: the signer's full public-key bundle of section 4, embedded as a CBOR array value (not wrapped in a bstr), for self-describing envelopes. `-70009` attestations: an array of nested envelopes (inline attestations the recipient may need). `-70012` inclusion-proof: the signed inclusion proof of section 9. The unprotected header is not signed, so anything read from it is verified against signed data before use: the bundle is checked against the `signer` Agent ID (step 3), attestations as envelopes (step 9). `-70009` and `-70012` are processed at step 9 (sections 7 and 9). **Payload.** Arbitrary bytes, or `nil` when detached (the digest in the protected header binds it). A detached payload is delivered out of band: the verifier is given the payload bytes together with the envelope, and MUST reject at step 4 when it is asked to verify a detached envelope without them. Both signatures and the digest cover those out of band bytes exactly as they would an attached payload. **Encoding rules.** Every ATEP structure (envelopes, headers, bundles, payloads defined by this specification) uses deterministic CBOR per RFC 8949 section 4.2.1: shortest-form integer and length heads, definite lengths only, map keys sorted by the bytewise lexicographic order of their encodings, no duplicate keys. ATEP uses no floats, no `undefined` and no tags other than 98 and 96; integers fit a signed 64-bit range. The only types used are integers, byte strings, text strings, arrays, maps, the simple values `false`, `true` and `null`, and the two tags; any other simple value or float is rejected. This applies to receivers as well as signers: a verifier MUST reject any ATEP structure that is not encoded this way, at step 1, because re-encoding a parsed value (for the Agent ID, for example) would otherwise accept non-canonical bundles. Implementations MAY bound nesting depth (the reference implementation allows 32 levels) and MUST reject trailing bytes after the single top-level item. **JSON debug rendering.** Any ATEP envelope has a defined JSON view for logs and documentation: CBOR maps become objects, byte strings become base64url, labels become their names. The JSON view is informational; signatures are only ever computed over the CBOR form. **Size.** Measured on the test vectors, a minimal signed envelope with a 1 KB payload is about 4.7 KB without an inline bundle: about 3.4 KB of hybrid signatures (64 B plus 3,309 B), about 280 bytes of protected headers and COSE framing (the attestation media type alone makes the protected header about 175 bytes), plus the payload. With a signing-only signer bundle inline (about 2 KB) the same envelope is about 6.7 KB. Encrypting adds about 1.25 KB (ML-KEM-768 ciphertext of 1,088 B, ephemeral X25519 key, recipient kid, iv, tag and framing). ## 6. Cryptographic suites ATEP v1 defines one mandatory suite, `ATEP-1`, at NIST security level 3. The suite is quantum-safe: its post-quantum components are the finalized NIST standards, and the hybrid construction keeps the protocol secure even if either the classical or the post-quantum half were broken. Every key, signature and ciphertext carries a COSE algorithm identifier, so later suites can be introduced without changing the envelope. | Role | Classical | Post-quantum | Notes | | --- | --- | --- | --- | | Signature | Ed25519 (EdDSA) | ML-DSA-65 (FIPS 204) | Both REQUIRED; both must verify | | Key encapsulation | X25519 | ML-KEM-768 (FIPS 203) | Hybrid KEM, key derived as specified under "Key derivation" below | | Payload encryption | AES-256-GCM | none | Alternate: XChaCha20-Poly1305 for platforms without AES hardware | | Key derivation | HKDF-SHA-256 | none | Context string `ATEP-1-KEM` prefixes the HKDF `info` and binds the derivation to this protocol | | Hashing | SHA-256 | none | Agent IDs, payload digests, Merkle tree, evidence hashes | | Signature size | 64 B | 3,309 B | \~3.4 KB total per envelope | | Public key size | 32 B | 1,952 B | \~2 KB per identity bundle | **Hybrid signing rule.** The two signatures are independent COSE\_Signature entries over the same Sig\_structure. Security holds if either algorithm remains unbroken. A verifier MUST NOT accept an envelope with only one of the two present, unless a future version explicitly defines a single-algorithm suite. **Hybrid KEM rule.** Both encapsulations are performed; the two shared secrets are concatenated and run through HKDF with the protocol context and the public values. Compromise of one KEM does not expose the key. **Encrypted envelope wire format.** An encrypted envelope is COSE\_Encrypt (RFC 9052 section 5.1), CBOR tag 96, with exactly one recipient: ``` 96([ protected: bstr, ; encodes {1: 3, -70001: 1, -70007: "ATEP-1"} { 5: iv }, ; iv is 12 bytes ciphertext: bstr, ; AES-256-GCM output followed by the 16-byte tag [ [ recipient_protected: bstr, ; encodes {1: -70011} { 4: kid, ; recipient Agent ID, 32 bytes -1: eph_key, ; COSE_Key {1: 1, 3: -25, -1: 4, -2: ephemeral X25519 public key} -70013: kem_ct }, ; ML-KEM-768 ciphertext, 1,088 bytes h'' ] ] ]) ``` Content algorithm `3` is AES-256-GCM, `-70011` is the provisional identifier of the `ATEP-1` hybrid KEM recipient algorithm, and the ephemeral key reuses the X25519 COSE\_Key encoding of section 4. The outer protected header carries the version and suite so that step 1 can check them before any decryption (section 10). Every structural property of the recipient, including the sizes of the ephemeral key and of the KEM ciphertext, is also checked at step 1, so that step 2 only ever sees a well-formed structure. **Key derivation.** The sender generates an ephemeral X25519 keypair and computes `ss_x25519 = X25519(eph_sk, recipient_x25519_pub)`, rejecting an all-zero result, and `(mlkem_ciphertext, ss_mlkem768) = ML-KEM-768.Encaps(recipient_mlkem_pub)` (FIPS 203). The 32-byte content key is ``` key = HKDF-SHA-256(salt = empty, ikm = ss_x25519 || ss_mlkem768, info = "ATEP-1-KEM" || eph_x25519_pub || recipient_x25519_pub || mlkem_ciphertext, L = 32) ``` where `"ATEP-1-KEM"` is the 10 ASCII bytes of the context string and the other `info` parts are raw bytes (32, 32 and 1,088). Binding the public values and the KEM ciphertext follows combiner practice for X25519-based hybrids and costs nothing. The AEAD is AES-256-GCM with the 12-byte `iv` from the unprotected header and additional authenticated data equal to the encoded RFC 9052 Enc\_structure `["Encrypt", protected, h'']`, with `protected` as a byte string. The plaintext is the encoded inner tag 98 envelope (sign-then-encrypt). The recipient reverses the process with its X25519 secret key and its ML-KEM-768 decapsulation key. **Agility.** New suites are registered by name (`ATEP-2`, …) with their COSE identifiers. Verifiers advertise accepted suites; signers choose the strongest mutually supported one. Downgrade is prevented because the `suite` field is in the protected, signed header (sections 5 and 11). **Why level 3, not 5.** ML-DSA-87 and ML-KEM-1024 roughly double sizes for a margin NIST does not consider necessary today. Agility makes stepping up later a non-breaking change. **Why hybrid, not PQ-only.** ML-DSA and ML-KEM are new standards. Pairing them with Ed25519 and X25519 protects against an undiscovered flaw in either family, which is the practice Signal, Chrome and Cloudflare have adopted. ## 7. Attestations An attestation is an ordinary ATEP envelope whose payload is a claim about a subject identity. It uses the same format and crypto as data envelopes, so one verifier handles both. **Payload (CBOR map), content type `application/atep-attestation+cbor`:** | Field | Type | Meaning | | --- | --- | --- | | `subject` | Agent ID | The identity being vouched for | | `issuer` | Agent ID | Must equal the envelope's `signer` header | | `claim` | URI | The claim type, e.g. `https://atep.dev/claims/audited` | | `data` | map | Claim-type-specific fields (scope, policy version, audit date) | | `evidence` | SHA-256 | Optional digest of a supporting document | | `evidence-uri` | URI | Optional location of that document | | `id` | 16 bytes | Unique attestation ID, random; the handle used in revocation lists | **Schema rules.** The payload is a deterministic CBOR map with text keys. A consumer rejects it as a schema failure (`attestation_schema_invalid`, section 10) when it does not decode strictly, is not a map, has a key that is not a text string, lacks any of `subject`, `issuer`, `claim`, `data` or `id`, carries a key that is not in the table, or has a field of the wrong type or size: `subject` and `issuer` are byte strings of 32 bytes, `id` of 16 bytes, `evidence` of 32 bytes, `claim` and `evidence-uri` are URIs, and `data` is a map (possibly empty) whose keys are all text strings. A URI here is a text string of the form `scheme:rest`, where `scheme` is an ASCII letter followed by letters, digits, `+`, `.` or `-`, and `rest` is not empty; nothing more is checked. After the schema, `issuer` MUST equal the envelope `signer` (`attestation_issuer_mismatch`). Validity comes from the envelope's `issued-at` and `expires-at` headers, which are REQUIRED for attestations: a verifier rejects an envelope whose content type is `application/atep-attestation+cbor` and which has no `expires-at` at step 1 (section 10). Validity SHOULD be short, with automatic re-issuance. Lifetimes are tiered: | Class | Lifetime | Level | | --- | --- | --- | | Default | 30 to 180 days | SHOULD | | Audit-backed claims (`audited`, `safety-certified`, and any attestation that carries an `evidence` hash) | up to 400 days | MAY | | Any attestation | more than 400 days | MUST NOT | A verifier MUST reject an attestation whose `expires-at` minus `issued-at` exceeds 400 days (`attestation_lifetime_exceeded`); exactly 400 days is allowed. Audit-backed claims may run longer because audits are commonly annual, but the issuer still publishes revocations through its SRL if the underlying audit is withdrawn. The 180 day default is enforced by issuers only: an issuing tool refuses a longer lifetime for a claim that is not audit-backed unless the operator overrides it, and verifiers enforce only the 400 day maximum, because a verifier cannot meaningfully police a SHOULD. **Evidence requirement.** The claims `audited` and `https://atep.dev/claims/robotics/safety-certified` REQUIRE the `evidence` field. Every consumer of an attestation of those types (a verifier at step 9, a log at admission) rejects one without it as `attestation_schema_invalid`. An attestation of any type that carries `evidence` is audit-backed for the purpose of the lifetime tiers. **Open vocabulary.** A claim type is any URI under the issuer's control. The URI SHOULD resolve to a human-readable definition and a CDDL schema for `data`. For the core claim types under `https://atep.dev/claims/` it MUST: an HTTP `GET` of `https://atep.dev/claims/` (for example `https://atep.dev/claims/audited` or `https://atep.dev/claims/robotics/fleet-member`) returns, as JSON by default and as HTML for a client that prefers `text/html` or for a path with the suffix `.html`, the definition (`definition`, a one sentence text, and `description`), who issues it, the CDDL of `data` (`data-schema`), the evidence and lifetime rules and an example. A claim type that is not defined is `404`. Registries serve the same documents (section 9, "Claim-type resolution"). ATEP reserves `https://atep.dev/claims/` for a small, closed core set of 14 claim types: the seven below and the seven robotics claims of section 17 (`operator` is shared). Only these 14 may be issued under the reserved namespace; a log refuses any other claim type there (section 9), and a new core claim type requires a later draft after public review. Anyone may use their own namespace. | Claim | Issued by | Asserts | | --- | --- | --- | | `domain-control` | Domain owner | Subject is authorized for this DNS name (proven by DNS or well-known record) | | `operator` | Any issuer | Subject is operated by the named legal entity | | `successor` | Subject's old identity | Subject replaces the issuer's identity (key rotation) | | `retired` | Subject itself | Subject is permanently retired | | `issuer-authority` | Root issuer or a delegate | Subject may issue the listed claim types (chain delegation) | | `audited` | Auditor | Subject passed the named audit on the given date; evidence hash required | | `registry-endpoint` | The endpoint's operator, usually the subject or its domain owner | Subject can be reached as the named kind of service at the given URL (registry, verifier, MCP server, A2A agent) | Policy claims such as data handling, training exclusions, retention periods or sector compliance are expected to be defined by certification bodies and industry groups under their own namespaces. **Data layouts of the core claims.** The layouts below are checked by the consumers named in the last column (the core verifier of steps 1 to 8 checks none of them); for every field not listed the `data` map is opaque to the protocol. Keys are text strings. | Claim | `data` | Checked by | | --- | --- | --- | | `issuer-authority` | `{"claims": [* URI]}`, REQUIRED: the claim types the subject may issue. To let the subject delegate further the list MUST contain the `issuer-authority` URI itself. An empty list delegates nothing | every consumer (`attestation_schema_invalid`) | | `domain-control` | `{"domain": tstr}`, REQUIRED by logs: a lowercase DNS name, labels of 1 to 63 characters from `a` to `z`, `0` to `9` and `-` (no label begins or ends with `-`), at most 253 characters in total, no trailing dot | logs at admission (section 9); the core verifier does not check it | | `retired` | `{}` or `{"reason": tstr}`, other members free and ignored; `subject` MUST equal `issuer` | every consumer (`attestation_schema_invalid`), and step 8 ("Retirement and succession" below) | | `successor` | `{}` or `{"reason": tstr}`, other members free and ignored; `issuer` is the old identity, `subject` the new one and MUST differ from it | every consumer (`attestation_schema_invalid`), and the succession rule ("Retirement and succession" below) | | `operator`, `audited` | free map; recommended `{"name": tstr}` and `{"audit": tstr, "date": tstr}` | not checked (`audited` needs `evidence`) | | `registry-endpoint` | `{"url": tstr, "kind": tstr}`, REQUIRED: `url` is an `https` URL of at most 2,048 characters with a host, no credentials and no whitespace; `kind` is `registry`, `verifier`, `mcp`, `a2a` or an extension name `x-` followed by one or more lowercase letters, digits and `-`. Other members are free. The exact URL and kind rules are in "Registry endpoints" below | logs at admission (section 9, `schema_invalid`); the core verifier does not check it | | robotics claims | free map, except `peer-motion`: `{"peers": [* Agent ID as bstr of 32 bytes]}`, REQUIRED for the ATEP-R `motion` rule (section 17) | section 17 | **Registry endpoints.** A `registry-endpoint` attestation binds a service endpoint to an Agent ID through the ordinary attestation machinery, so a client that holds an Agent ID can find where that identity runs a service and can check that the statement was logged. The subject is the Agent ID the endpoint belongs to (for a registry, the log's own identity). The kinds are: `registry`, an ATEP log and registry API as in section 9 (the OpenAPI description is at `/openapi.json`, and `` is the base the endpoints are appended to); `verifier`, a service that verifies envelopes for callers; `mcp`, a Model Context Protocol server; `a2a`, an A2A agent. The claim says only that the issuer states the endpoint belongs to the subject; whether to rely on it is the verifier's policy and chain, as for every claim. It describes services and never records interactions with them (section 16 point 5). Lifetimes follow the tiers above. *The `url` and `kind` rules, exactly.* They are checked as a pure function of the attestation `data`, in the order `url` then `kind`, and a violation is `schema_invalid` at a log. `url` MUST be text, and (1) it begins with the lowercase scheme text `https://` (the scheme is compared exactly: `HTTPS://` is not accepted, although RFC 3986 treats scheme names as case insensitive); (2) it is at most 2,048 characters, counted as Unicode scalar values (a URL of exactly 2,048 is accepted, one of 2,049 is not); (3) it contains no ASCII white space or control character (U+0000 to U+0020 and U+007F); (4) its authority, the text after `https://` up to the first `/`, `?` or `#`, is not empty, contains no `@` (an `@` anywhere in the authority is credentials) and does not begin with `:`, so `https:///v1`, `https://:8443/` and `https://?x` have no host. Nothing else is checked: the characters of the host, the range of the port and the path are not. Two edges are not decided by this draft because the implementations differ and no vector covers them (section 12, known gap 21): a non ASCII white space, control or format character in the URL (the Rust and npm implementations accept it, the Python implementation refuses any character that is white space or of Unicode general category `Cc`, `Cf`, `Zs`, `Zl` or `Zp`) and a bracketed host literal that is not closed, as in `https://[` (accepted by Rust and npm, refused by Python); an issuer SHOULD avoid both. `kind` MUST be text: `registry`, `verifier`, `mcp` or `a2a`, written in lowercase, or `x-` followed by one or more of the lowercase letters `a` to `z`, the digits and `-`, with a hyphen allowed anywhere after `x-` (`x-` alone, `x-Acme` and `x-acme_queue` are refused; `x-a-` and `x--` are accepted by all three implementations and the CDDL, and no vector covers them). This kind rule is looser than the extension rule of `chain-id` (section 9), which does not allow a hyphen at either end; the two differ as written and a later draft may align them. *Carrying a signed agent card.* For `kind` `a2a` the agent's card (A2A "agent card", conventionally at `https:///.well-known/agent.json`) is bound by hashing it: `evidence` is the SHA-256 of the card's bytes exactly as the service serves them, and `evidence-uri` is the `https` URL they are fetched from. A client verifies by fetching `evidence-uri`, hashing the response body and requiring equality with `evidence`; a mismatch means the card is not the one the issuer vouched for (it changed since, and the issuer has to issue again). Because the hash covers the served bytes, a publisher that wants a stable binding serves a static document. The card keeps whatever signature the A2A specification gives it; the ATEP attestation does not depend on it and adds the issuer's signature, a lifetime, revocation through an SRL and a place in the log. `data.url` SHOULD equal the `url` of the card, and a client that parses the card SHOULD check that. The same method carries a description of any other kind of endpoint (an MCP server manifest, an OpenAPI document). **Checking a domain binding.** Before it issues `domain-control` for subject S and domain D, an issuer MUST check the domain records of D (section 4) in this order and with these results: 1. D is a canonical lowercase DNS name, as `data.domain` requires (labels of 1 to 63 characters from `a` to `z`, `0` to `9` and `-`, none beginning or ending with `-`, at most 253 characters, no trailing dot, no empty label, no underscore), and is not a public suffix. A name that is not canonical is *refused*: nothing is read, neither the well-known document nor any TXT name is asked for, both sources have the state *not read*, and the result is *not bound* (`Example.com`, `example.com.`, `_atep.example.com` and `a..example.com` are refused even when records for a cleaned up name list S). An issuer SHOULD refuse names that the public suffix list marks as suffixes, and SHOULD treat a name below a shared hosting suffix as proof of control of that hostname only; the public suffix test needs a list that no implementation of this suite carries, so it is outside the language neutral cases and has no vector (section 12, known gap 20). 2. It reads the well-known document and the TXT records of `_atep.D`, each as defined in section 4, and nothing else (no parent name, no search up the tree, no subdomain), and gets for each source one of: *listed* (valid and names S), *not listed* (valid, does not name S), *absent*, *invalid* (exists but breaks a rule of section 4) or *unavailable* (timeout, connection or TLS failure, `429`, `5xx`, DNS `SERVFAIL`, a DNSSEC validation failure). A host or name for which the fetcher answers that nothing exists is *absent*, silently, like a `404` or empty TXT data. 3. The result is *bound* when a source is listed and no valid source is not listed; *indeterminate* when no source is listed and a source is unavailable (this includes a source that is unavailable next to a valid source that does not list S); *not bound* otherwise (nothing lists S, the sources contradict each other, or the only sources are absent or invalid). When the issuer requires both sources (`require_both`, section 4) the result is *bound* when both are listed; *not bound* when either source is absent, invalid or not listed, whatever the other one says; and *indeterminate* otherwise, that is when neither source is a definite no and at least one is unavailable (a listing next to an unavailable source, or two unavailable sources). 4. The issuer issues only on *bound*. On *not bound* it MUST NOT issue. On *indeterminate* it MUST NOT issue now and SHOULD retry; a failure never defaults to issuing. *Caching and time to live.* A result is evidence of one moment. An issuer MUST NOT issue on a result older than one hour, whatever the HTTP cache headers or the DNS TTL say; it MAY use a shorter life from them. A *not bound* or *unavailable* result SHOULD NOT be cached for more than five minutes, so that an owner who just published a record is not kept waiting. The check is repeated at every re-issuance of the attestation, and a `domain-control` attestation SHOULD be issued for 30 to 90 days. Between issuances the issuer SHOULD re-check at least daily; when the domain stops listing S, the issuer SHOULD revoke the attestation in its SRL within 24 hours (section 8). Monitors that watch a domain re-check at least daily as well. *Failure behavior.* The check fails closed: no outcome other than *bound* allows issuance. The result of the check is not carried in the attestation; an issuer MAY keep the fetched bytes in its own records and MAY put their SHA-256 in `evidence`, noting that an attestation with `evidence` is audit-backed for the lifetime tiers (above). *Security notes.* - The binding shows control of a DNS name at the time of the check. It is not proof of legal ownership or of the identity of the operator; the `operator` claim says that. - DNS TXT answers can be forged by an on-path attacker unless they are DNSSEC validated. An issuer SHOULD use a validating resolver and treat a validation failure as *unavailable*, never as a reason to retry without validation. An issuer MAY refuse unvalidated TXT answers (the option `require_dnssec` of the reference checker) and then relies on the well-known document alone. The state that a refused answer takes is not decided by this draft, because the implementations differ and no vector sets the option (section 12, known gap 20): the Rust implementation labels it *invalid* when a record counts (and *absent* when none does), the Python implementation labels it *unavailable* in both cases. Under either label a listing in the well-known document still binds, and where nothing lists the Agent ID neither binds: the first gives *not bound*, the second *indeterminate* (retry later), and an issuer never issues on either. The well-known document depends on the WebPKI, so it is the stronger source for domains that are not signed. - The domain is chosen by the requester, so the issuer's fetcher is exposed to request forgery: it MUST NOT connect to loopback, private, link-local or otherwise non-public addresses (checked after resolution, on the address actually used), SHOULD time out within 10 seconds, MUST enforce the size limits above and MUST NOT send credentials. - Redirects are the usual way a document is served from a place its owner did not intend (an open redirect, a tenant of a shared host); the redirect rules of section 4 close that. A subdomain is a different name: its owner, not the owner of the parent, controls its records. - The log cannot see DNS. Whether the issuer really checked is not decidable from the log; monitors alert on a `domain-control` attestation for a watched name whose subject the owner does not recognize (section 9, `unauthorized_domain_control`), and domain owners SHOULD run one. - The records list Agent IDs, which are pseudonymous public identifiers. Publishing them reveals which identities a domain authorizes, and nothing else. *Reference check.* `check_domain_binding(domain, agent_id, fetcher)` in the Rust reference (`atep-core`, module `domain`, re-exported by `atep-log` as `domain_binding`) implements this check over a `DomainFetcher` trait with `fetch_well_known` and `fetch_txt`; the library has no network code of its own, so an issuer plugs in its HTTPS and DNSSEC capable fetcher. The Python implementation has the same check as a function of a fixture (`check_binding`). The `domain-binding` vectors (section 12) run the check against a fake fetcher that answers from a fixture and records what it was asked, which is how `not read` is observable: the Rust library reports a refused name as *invalid* for both sources and the vector result says *not read* because nothing was queried, with the same outcome. The check is informative; the vectors are the executable form. **Short claim names.** Trust policies (below) and the CLI accept a short name for a claim type. The expansion of a name that contains a colon is the name itself. Otherwise, if `https://atep.dev/claims/` is one of the seven core claim types, or the name begins with `robotics/`, the expansion is `https://atep.dev/claims/`; otherwise, if `https://atep.dev/claims/robotics/` is one of the seven robotics claim types, that is the expansion; any other name expands to `https://atep.dev/claims/`. So `audited` and `registry-endpoint` are core claims, `fleet-member` and `robotics/fleet-member` are the same robotics claim, and `operator` is the core claim. Attestations and SRLs always carry full URIs. **Retirement and succession.** This subsection is normative and complete for the two claims. The cases a conforming implementation has to get right are tabulated in section 12 ("Cases for `retired` and `successor`"), so that vectors can be built from the text alone. An *identity revocation* is anything that makes step 8 reject an identity from an instant on: a 32 byte SRL entry (section 8), a directly supplied revocation entry, or a valid retirement (below). `retired` and `successor` are ordinary attestations (the format, the lifetime tiers and candidate validation apply to them) with the additional rules that follow. Both are implemented in the Rust, npm and Python implementations and have vectors (section 12). *`retired`: layout and issuer.* `subject` MUST equal `issuer`, so only an identity can retire itself. `data` is `{}` or `{"reason": tstr}`; other members are free and ignored, and a `reason` that is not a text string is a schema failure. `evidence` is optional. A consumer (a verifier at step 9, a log at admission) rejects a violation as `attestation_schema_invalid` (`schema_invalid` at a log). No other identity, issuer or log can issue a `retired` attestation for an identity, and none can block or lift one (section 16 point 7); an SRL identity entry that another issuer publishes is a different thing (section 8). *`retired`: valid retirement.* A *valid retirement* of an identity X is an attestation R with claim `retired`, `subject` and `issuer` both X, signed by X, that satisfies all of the following, and nothing more: 1. It passes steps 1 to 7 of section 10 as an envelope at the verifier's `now`, except that `expires-at` is not compared with `now` at step 5 (retirement is permanent) and replay checking is off. The `issued-at` skew check of step 5 still applies. The bundle of X is the inline bundle of R, else a cached one (`known_bundles`), else the bundle that was resolved for the envelope being verified, which has the same signer; the order does not matter, because a bundle is accepted only when it hashes to X's Agent ID (step 3). No recipient identity is used, so an encrypted entry is not a valid retirement. 2. Its content type is `application/atep-attestation+cbor`, it is not encrypted, its payload passes the schema and the rules above, and `expires-at` minus `issued-at` does not exceed 400 days. 3. Step 8 is not applied to R itself (it would only compare X with itself), an SRL entry naming R's `id` is ignored (an issuer cannot lift its own retirement by withdrawing the attestation, and nobody else can either), and no inclusion proof is required. An R that fails any of these is ignored when step 8 looks for retirements; that is not an error there, and the same R is `attestation_invalid` or `attestation_schema_invalid` when it is used as a step 9 candidate. The two paths are separate: a `retired` attestation that is a step 9 candidate (for a rule whose claim is `retired`) goes through candidate validation like any other attestation, including the lookup of its issuer's SRL, where an entry naming its `id` is `attestation_revoked`, whereas the scan of step 8 ignores such an entry (item 3). A retirement takes effect at the `issued-at` of R, not when a verifier learns of it, so envelopes issued before it stay valid and envelopes issued after it are rejected whenever they arrive. *Entries of the store that are not retirements.* The store is a list of encoded attestations that nothing checked on the way in, so step 8 reads it defensively. An entry that does not decode as a tag 98 envelope with an attached payload, that is encrypted (tag 96), that is signed by someone other than X, that has another claim or another subject, or that fails any check above, is ignored: it is not an error, it does not make step 8 fail and it does not stop the other entries from being read. Of the checks above only steps 1 to 7 (with the exceptions named), the schema and layout, the lifetime and the signer are applied to an entry; step 8, replay checking and the clock comparison of `expires-at` are not. When the store holds several valid retirements of X, the one with the smallest `issued-at` decides, which is the earliest instant of the union rule (below). A retirement with an early `issued-at` rejects more of X's envelopes, never fewer, so a verifier needs no defense against back-dating; the holder of a compromised key can therefore also retire the identity, which is accepted because that identity is already untrustworthy. *`retired`: effect at step 8.* An envelope E signed by X is rejected at step 8 with `signer_revoked` when the verifier's local attestation store holds a valid retirement R of X and `R.issued-at <= E.issued-at`. The boundary is inclusive, as for SRL entries, so an envelope issued at the same second as R is rejected. One envelope is exempt from this rule: an envelope that is itself a `retired` attestation of X (content type attestation, payload with claim `retired` and `subject` and `issuer` both X), so that a retirement can be re-issued or renewed after the first and a log accepts a duplicate. The exemption looks at those things only: the content type, and a payload that decodes as a CBOR map whose `claim` is the `retired` URI and whose `subject` and `issuer` both equal the signer. It does not look at the rest of the payload, so a malformed `retired` attestation of X issued after R is exempt at step 8 and is then rejected by whoever interprets it with the schema error (`attestation_schema_invalid` at step 9, `schema_invalid` at a log) and not with `signer_revoked`; one with another subject is not exempt (decision 51). The exemption is from the retirement rule only; an SRL entry or a directly supplied revocation still applies to it. Step 8 applies in the same way to every attestation verified at step 9: candidate validation (section 10) makes the local attestation store available to its step 8 for retirements, and nothing else in the store is used there. *How a verifier learns of a retirement.* There are four routes, three of which count at step 8: 1. The local attestation store (section 7, "The verifier context"). It counts. A verifier SHOULD add to its store every valid retirement it comes across by any route: an inline attestation of a verified envelope, a lookup on a log (`claim` `retired` and the Agent ID as `subject`), an entry of a log it follows, gossip. A verifier that relies on an identity and can reach a log SHOULD look it up periodically. A store has no freshness: knowledge of a retirement is exactly as recent as the last time the store was filled. 2. An SRL identity entry, `{id: X as 32 bytes, reason, revoked-at}`, in any cached SRL (section 8). It counts, whatever its `reason` (the convention is `retired`), exactly like a `compromised` entry. An identity that also issues SRLs SHOULD list itself with `revoked-at` equal to the `issued-at` of R, in a final SRL published before R (see below), so that verifiers that follow SRLs and not logs learn of the retirement with the freshness semantics of section 8. 3. A directly supplied revocation entry (section 7, "The verifier context"). It counts. 4. An inline attestation of the envelope under verification (`-70009`). It does not count at step 8, because an envelope could simply omit it. Once the envelope has verified, route 1 applies to it. A log applies step 8 at admission (section 9) with the `retired` attestations it has logged as its local attestation store. *The SRL of a retired issuer.* An SRL is an envelope signed by its issuer, and step 8 applies to every envelope, so loading an SRL (section 8) fails with `signer_revoked` at step 8 when its issuer is revoked or retired as of the list's `issued-at`. A retired issuer therefore cannot publish any further SRL. Its last list stays in the cache and goes stale at `next-update`, after which the attestations it issued fail with `srl_stale` under fail-closed and pass with a warning under fail-open (section 8). An issuer that retires while it has unexpired attestations SHOULD, before retiring, publish a final SRL whose `next-update` covers how long it wants those attestations to stay usable, and SHOULD set the `revoked-at` of any entry that names its own Agent ID strictly later than the `issued-at` of that list: a list that names its own issuer from an instant at or before its own `issued-at` fails step 8 whenever it is loaded into a context that already holds it (a second load of the same bytes, for instance), and a verifier SHOULD treat a failed reload of the bytes it has cached as no change. *`retired` and attestations issued by the retired identity.* An attestation issued by X with `issued-at` before `R.issued-at` stays valid until it expires or its issuer's SRL withdraws it. One issued at or after `R.issued-at` is rejected: as a step 9 candidate the error is `attestation_invalid` with `cause {step 8, signer_revoked}`. This covers delegations: an `issuer-authority` attestation issued by X before its retirement keeps authorizing its subject until it expires or goes stale, one issued after does not count. The retirement of X does not affect attestations whose `subject` is X; X simply cannot sign any more. *`retired` and `compromised`.* Both are identity revocations. Step 8 does not look at the `reason`, and the two combine as a union: an envelope is rejected when any one revocation applies, so the effective instant of an identity is the earliest of all of them. A `compromised` entry cannot lift or postpone a retirement, a retirement cannot lift or postpone a `compromised` entry, and a `compromised` entry with an earlier `revoked-at` moves the instant back, which rejects the envelopes the identity issued between the two. A `retired` attestation issued at or after the `revoked-at` of a `compromised` entry for the same identity changes nothing, because everything the identity signed from that instant on is rejected anyway. *`retired` and ATEP-R.* Step 8 runs before step 9, and the e-stop exception of section 17 is an exception for claim expiry only. An e-stop signed by a retired identity with `issued-at` at or after its retirement is therefore rejected at step 8, like one signed by a compromised identity, and the robot keeps its last safe behavior. An e-stop whose attestations were issued by a retired issuer before its retirement passes when they are otherwise valid; the SRL behavior of the class applies to that issuer as to any other (the e-stop continues with a warning when the issuer's last list is stale, `motion`, `actuation`, `maintenance` and other `safety` commands fail closed with `srl_stale` or `srl_unavailable`). A retirement held only in a local store has no freshness signal, so a fleet operator SHOULD also list a retired unit in its own SRL, whose staleness a robot does notice. *`successor`: layout, issuer and lifetime.* `issuer` (the signer) is the old identity O and `subject` is the new identity S; `subject` MUST differ from `issuer`. `data` is `{}` or `{"reason": tstr}`, other members free and ignored, a `reason` that is not text a schema failure (`attestation_schema_invalid`, `schema_invalid` at a log). Only O can issue it; the agreement of S is outside the protocol, and the attestation transfers only claims that O holds. Its lifetime follows the tiers above (400 days at most) and bounds the succession: when it expires the succession ends, and an old identity that has retired cannot renew it. The `successor` attestation SHOULD be logged. *`successor`: following succession.* A verifier follows succession only when the trust policy sets `follow_succession` to true (section 7, trust policy; the default is false). With it false a `successor` attestation is an ordinary attestation and nothing else. With it true, step 9 evaluates a rule for claim C on signer S in this way. The ordinary evaluation of section 10 runs first. Only when it fails with `claim_missing`, that is when the pool has no candidate with `subject` S and claim C, the verifier looks for succession; any other failure is final, so that a negative result about S itself (an expired, too old or revoked attestation of S) is never overridden by something inherited. 1. The *successor candidates* are the pool entries with `subject` S and claim `successor` (candidate selection, loose reading), each validated by candidate validation (section 10), in pool order. A candidate that fails is skipped. 2. For each successor candidate, with issuer O, the *claim candidates* are the pool entries with `subject` O and claim C, validated in pool order and checked as for any rule (`max_age_days`, the `data` condition of the rule, authorization of the claim attestation's issuer by `authorize(issuer, C, roots, empty path, 2)`; the successor attestation counts as one attestation of the chain, so a chain through succession is one deeper than the same chain without it). 3. The first successor candidate and claim candidate pair that passes everything satisfies the rule. The verified claim reports the claim attestation as the first link of `chain`, the `successor` attestation as the second, then the `issuer-authority` links; `expires_at` is the earliest `expires-at` of all of them, so the claim is good no longer than the succession is. 4. When no pair passes, the rule fails with `claim_missing`, the error it already had. Following succession can only turn a `claim_missing` into a success, and a verifier that does not implement it reports the same error as one that does. The `peer-motion` data condition and ATEP-R class requirements work unchanged on inherited attestations, because they are rules of the same kind. *Path, depth and warnings under succession.* The path handed to `authorize` is empty, as written: neither O nor S is on it, and the walk pushes the claim attestation's issuer first, so `chain_cycle` is judged among the issuers of the claim attestation and its delegations only (a claim attestation that O issued about itself is not a cycle). The depth argument is 2 because the successor attestation is the second attestation of the chain. The depth test of `authorize` comes after the root test (rule 4 of "Chains"), so when the issuer of the claim attestation is a root nothing tests the depth: a claim issued by a root and inherited through succession is accepted with a chain of two attestations even when `max_depth` is 1. `max_depth` therefore limits the delegations an issuer needs, and the successor attestation raises the depth of a delegation walk (SU19, SU20) without ever being refused for it alone (decision 52). Warnings are recorded in the order in which step 9 first meets them (section 8): under succession the successor attestation, then the claim attestation, then its authority chain, and a warning met while validating a pair that then fails stays in the list when a later pair satisfies the rule; when the rule fails the list is not returned at all. *One hop.* Only attestations whose `subject` is exactly the issuer of a valid successor attestation naming S are inherited. The verifier never looks for a successor attestation that names O, so with O0 succeeded by O and O succeeded by S, S inherits what O holds and nothing that only O0 holds; for a claim that only O0 holds the rule fails with `claim_missing`. A second hop is not an error and is never followed. Monitors alert on a logged chain of two or more hops (`successor_chain`, section 9, and section 11). *`successor` and revocation.* Candidate validation applies step 8 to O as of the successor attestation's `issued-at`. If O is revoked or retired as of that instant (boundary inclusive), the successor is `attestation_invalid` with `cause {step 8, signer_revoked}` and is not followed. An old identity therefore issues the `successor` attestation strictly before it retires: issuing both in the same second does not work. A successor issued before a `compromised` entry's `revoked-at` stays valid, although O has been compromised since: the claim attestations about O keep their own validity and the issuer of the claim withdraws one through its SRL if it wants to (candidate validation checks that), and a verifier that is concerned about back-dating (a thief issuing a successor with an early `issued-at`) sets `require_inclusion`, which candidate validation applies to the successor attestation like to any other. An entry of O's SRL that names the `id` of the successor attestation withdraws it. O's SRL is looked up like that of any issuer, so a stale list of a retired O fails the successor attestation with `srl_stale` under fail-closed, which is why a retiring identity publishes a final list with a suitable `next-update` (above). **Chains.** An issuer's own identity carries attestations too. A verifier walks from the subject's claim attestation up through `issuer-authority` attestations until it reaches a root issuer in its trust policy, exactly like a certificate chain. The rules are exact: 1. *Roots.* The policy has a root set. A root is trusted for every claim type and needs no attestation. A rule MAY name one root of the set, in which case only that root ends the chain. 2. *Authorization.* An issuer I is authorized for claim C when I is a root, or when the pool (below) holds a valid `issuer-authority` attestation with `subject` I whose `data.claims` lists C and whose own issuer P is authorized for `issuer-authority` and, unless C is `issuer-authority`, also for C. The two requirements on P are checked in that order, and the check for C is a pure check (it contributes no link to the reported chain). Delegation cannot exceed the delegator's own authority. 3. *Depth.* Depth is the number of attestations in the chain, the claim attestation included, so a claim issued directly by a root has depth 1. The default maximum is 5 (`max_depth` in the policy). Authorizing a non-root issuer at depth d needs one more attestation, so it fails with `chain_depth_exceeded` when d + 1 exceeds the maximum. Each of the two checks of rule 2 counts its own attestations. 4. *Cycles.* An issuer that already stands on the path being walked is a cycle (`chain_cycle`). A root is accepted before the cycle and depth tests, so a root never counts as a repeat. 5. *Walk order and errors.* Authorization of I for C proceeds as in the pseudocode below. Candidates are tried in pool order, the first success wins, and if every candidate fails the error of the first candidate is the error of the walk. A non-root issuer with no `issuer-authority` candidate in the pool is `chain_broken`; a candidate whose `data.claims` does not list C is `issuer_not_authorized`; a candidate that fails candidate validation reports that failure. ``` authorize(I, C, roots, path, depth): ; depth counts attestations so far, at least 1 if I in roots: return (links = [], root = I) if I in path: reject chain_cycle if depth + 1 > max_depth: reject chain_depth_exceeded push I on path candidates = pool entries with subject I and claim issuer-authority, in pool order if candidates is empty: pop I; reject chain_broken first_error = none for each candidate c: attempt: v = validate(c) ; candidate validation, section 10 if C not in v.data.claims: reject issuer_not_authorized P = v.issuer up = authorize(P, issuer-authority, roots, path, depth + 1) if C != issuer-authority: authorize(P, C, roots, path, depth + 1) pop I; return (links = [v] + up.links, root = up.root) on rejection e: first_error = first_error or e pop I; reject first_error ``` A rule for claim C on subject S is evaluated by taking the claim attestation candidates (pool entries with `subject` S and `claim` C), validating each, applying `max_age_days` and the `peer-motion` data check, and calling `authorize(issuer, C, roots, empty path, 1)`. The reported chain is the claim attestation followed by the `links` returned, and `root` is the root of `up` (the `issuer-authority` walk). **The attestation pool.** Step 9 gets its attestations from three untrusted sources, in this order: the signed envelope's own `-70009` array, in array order, where each attestation is immediately followed by the attestations nested in its own `-70009` (depth first, nesting up to 8 levels); then the verifier's local attestation store, in store order. Entries are deduplicated by the SHA-256 of their encoding, and at most 256 are kept (later ones are dropped). Pool entries are re-encoded from their parsed CBOR value, which equals the transmitted bytes because ATEP CBOR is deterministic. Nothing from the pool is trusted until it has passed candidate validation (section 10). **Candidate selection.** Candidates are selected on a loose reading of each pool entry, before any validation: the entry decodes as a tag 98 envelope with an attached payload, the payload decodes as a CBOR map, and its `subject` is a 32-byte string and its `claim` a text string. An entry that does not meet this is silently not a candidate for anything, so a rule whose only attestation is that malformed reports `claim_missing`. An entry that does meet it is a candidate whenever `subject` and `claim` match, and any other defect it has is reported by candidate validation rather than skipped. Replay checking (step 6) is not applied to attestations, which are meant to be reused. **Trust policy.** The verifier, not the protocol, decides what is sufficient. A policy is a list of rules of the form: *require claim C from an issuer chaining to root R, not older than N days*. This is how "responsible" becomes concrete: a bank and a hobby project can run the same protocol with different policies. A policy is a JSON object (RFC 8259) whose members are all optional; an unknown member name is an error, so that a typo cannot silently weaken a policy. A policy that does not parse (an unknown member, a member of the wrong type, a malformed rule) is a configuration error, not a verification result, and an implementation MUST refuse to verify with it and release no payload. The configuration error is called `policy_invalid`, the name that step 9 also uses for a rule that names a root outside `roots`: an API that can only return verification results reports `{ok: false, step: 9, error: policy_invalid}`, and one with a separate error channel (an exception or an error value) reports it there; nothing is evaluated either way. The members that exist are exactly those of the table below, so a verifier of this draft rejects a member that a later draft might define, and it never treats a member it knows (`require_anchor`, `follow_succession`) as unknown. The CDDL is in `spec/schemas/atep.cddl` (`trust-policy`). | Member | Type | Meaning and default | | --- | --- | --- | | `roots` | array of Agent ID texts (`atep:` or `did:atep:` form) | The trusted root set. ATEP ships with none, so the default is empty and nothing chains | | `rules` | array of rule objects | Every rule MUST be satisfied. Default: none | | `max_depth` | integer, at least 1 | Maximum attestations in a chain, claim attestation included. Default 5 | | `require_inclusion` | boolean | Every attestation relied on, the claim attestation and each delegation, MUST carry a valid inclusion proof (section 9). Default false | | `require_anchor` | array of anchor rules | Optional, default empty. Each rule means: accept checkpoints of the log `log` only if anchored on `chain` within the maximum age. Not evaluable in Draft 06: while the array is not empty, step 9 fails closed (below) | | `trusted_logs` | array of Agent ID texts | Logs whose checkpoints are trusted. Default empty | | `srl` | object `{on_stale?, on_missing?}`, each `"fail-closed"` or `"fail-open"` | Behavior for a stale or absent SRL (section 8). Defaults `on_stale` fail-closed and `on_missing` fail-open; an unknown member of `srl` is an error | | `atep_r` | boolean | Treat the channel as ATEP-R (section 17). Default false | | `follow_succession` | boolean | Follow one hop of succession for a rule that failed with `claim_missing` ("Retirement and succession" above). Default false. New in Draft 04 | A rule is an object with `claim` (REQUIRED: a URI or a short name), `root` (an Agent ID text that MUST be in `roots`) and `max_age_days` (a non-negative integer); an unknown member is an error. The rule is satisfied by a claim attestation on the signer whose `issued-at` is at most `max_age_days` days before now, that is, `now - issued-at <= max_age_days * 86400` (an attestation issued exactly `max_age_days` ago passes). A rule that names a root outside `roots` is rejected with `policy_invalid` at step 9, when the rule is evaluated and before its candidates are looked at. With no `rules`, no `require_anchor` rule and `atep_r` false, step 9 evaluates nothing and succeeds. An anchor rule is an object with the REQUIRED members `log` (an Agent ID text, `atep:` or `did:atep:`), `chain` (a `chain-id`, registered or extension, section 9) and exactly one of `max_age_days` or `max_age_hours` (a JSON integer of at least 1); an unknown member (the spelling `require-anchor` as a policy member is one), a missing member, both age members, a malformed Agent ID, an invalid `chain-id`, a rule that is not an object and a `require_anchor` that is not an array are policy configuration errors (section 20, decision 20: not a verification result). One bad rule among good ones makes the whole policy invalid. Grammar: ``` require-anchor = { log: agent-id, chain: chain-id, (max_age_days: uint .ge 1 // max_age_hours: uint .ge 1) } ``` *Values of an anchor rule.* An age is a JSON integer token, written without fraction or exponent, from 1 to 2^64-1: `0`, a negative number, `1.5`, `1.0`, `1e0`, `true`, text, `null` and a number above 2^64-1 are errors (a JSON boolean is not a number, even where a language treats `true` as 1). The parsed result lists the rules in array order, with `log` in the canonical `atep:` form (a `did:atep:` text is accepted and normalized), `chain` as written and the one age member as an integer; the same rule may appear twice, which changes nothing because the rules are a conjunction; `[]` and an absent member are the same, no rules. The vectors (`require-anchor`, 27) cover every shape above except a number that is not an integer or is above 2^63-1, because the vectors carry the policy as deterministic CBOR, in which such a number cannot be written (section 12, known gap 23). *Where `policy_invalid` is raised.* A policy is parsed from its JSON before it is used, and a policy that does not parse is a configuration error with no step of its own. An implementation may parse the policy when it is configured, before any envelope is looked at (the Rust and npm implementations: the error is returned or thrown and no envelope is verified), or inside step 9 and report `{ok: false, step: 9, error: policy_invalid}` (the Python implementation: an envelope that fails steps 1 to 8 reports that earlier failure, because the policy is never reached). Either way no payload is released and nothing of the policy is evaluated, and no vector separates the two positions (section 12, known gap 23). The other `policy_invalid`, a rule that names a root outside `roots`, is a step 9 result raised when the rule is evaluated. A policy parse vector (`require-anchor`) has `inputs.policy` (the JSON) and `expected` either `{ok: true, require_anchor: [...]}` or `{ok: false, error: "policy_invalid"}` with no step; the runner of an API that has only results maps its step 9 result to that expectation. **Evaluation of `require_anchor` in Draft 06.** Evaluating a `require_anchor` rule requires fetching and checking anchors, which this specification does not define yet (milestone M5, section 13). A verifier that does not implement evaluation, and every implementation of Draft 04, 05 or 06 that has not shipped M5, MUST NOT ignore the rule and MUST fail closed: while the effective policy contains at least one rule, step 9 rejects the envelope with `anchor_not_supported` (the message text is informative, for example "require-anchor is not supported in this build"), whether or not the other rules would have passed and whether or not the envelope would otherwise reach a rule. It is an ordinary typed step 9 rejection, with the same shape as any other, reported after steps 1 to 8 have passed and applied once, to the envelope itself and not to the attestations verified inside it. A policy with no `require_anchor` member, or an empty array, behaves exactly as under Draft 03. What is decided and what is not (section 14): the rules of the array are a conjunction, so a policy that wants two witnesses lists two rules and every rule must be satisfied once evaluation exists; how an anchor is fetched and checked against its witness, and the exact staleness measure, are not decided, and until they are the refusal above is the only defined behavior. A verifier that rejects the member as unknown fails closed too, but reports `policy_invalid` where this paragraph requires `anchor_not_supported`; the Python implementation did so until the `anchor-not-supported` and `require-anchor` vectors existed, and all three implementations now pass them (9 and 27 vectors). The vectors fix the order: an envelope that fails step 2 or step 8 is reported at that step, and the refusal is not given to a verification with no trust policy. **The verifier context.** Besides the envelope and the clock `now`, a verification takes these inputs, none of which is part of the envelope: the recipient identity (to open tag 96); `known_bundles` (cached signer bundles, looked up by Agent ID); `seen_nonces` (for step 6); directly supplied identity revocations `{id, reason, revoked-at}` (step 8); the out of band payload of a detached envelope; the allowed future skew (default 300 s); the trust policy (absent means step 9 is skipped); the local attestation store (encoded attestation envelopes, the third pool source and the place where step 8 looks for `retired` attestations); the SRL cache (section 8); and, for inclusion proofs, either the offline checker over `trusted_logs` or an online checker. ## 8. Revocation Revocation works in three layers, cheapest first, so that most withdrawals cost nothing and the rest are verifiable offline. 1. **Expiry.** Attestations are short-lived, 400 days at most (section 7). An issuer withdraws trust by not renewing. This handles routine cases with no extra machinery. 2. **Signed revocation lists (SRL).** Each issuer publishes an SRL at `https:///.well-known/atep-revocations.cbor`, also recorded in the transparency log. An SRL is an ATEP envelope with content type `application/atep-srl+cbor` carrying no `expires-at`, whose payload is the map below. Entries stay on the list until the attestation they name has expired. 3. **Identity revocation.** An identity itself is revoked by a `retired` self-attestation (planned retirement, section 7) or by an issuer publishing a `compromised` entry naming the Agent ID in its SRL. Afterwards every envelope signed by that identity whose `issued-at` is at or after `revoked-at` (for a retirement, at or after the `issued-at` of the `retired` attestation) MUST be rejected (verification step 8; the boundary is inclusive, so the check fails safe). The sources combine as a union, and the reason does not matter (section 7, "Retirement and succession"). **SRL payload.** A deterministic CBOR map with text keys, no others allowed: | Field | Type | Meaning | | --- | --- | --- | | `issuer` | Agent ID (32 bytes) | MUST equal the envelope `signer` | | `sequence` | unsigned integer | Increases with every new list of this issuer | | `issued-at` | unsigned integer | When the list was issued; signers set the envelope `issued-at` equal to it, verifiers do not compare them | | `next-update` | unsigned integer | MUST be later than `issued-at` | | `revoked` | array of entries | `{id, reason, revoked-at}` | An entry has exactly `id` (a byte string of 16 or 32 bytes), `reason` (text) and `revoked-at` (unsigned integer). **The meaning of `id` follows its length.** A 16 byte `id` names an attestation, and the entry applies only to attestations issued by the SRL's own issuer; an attestation is revoked by any entry naming its `id`, whatever the `revoked-at`. A 32 byte `id` is an Agent ID, and the entry is an identity revocation: the named identity is revoked from `revoked-at` on, as seen by step 8. `reason` is free text with the initial values `superseded`, `withdrawn`, `compromised` and `retired`; any other text is accepted. The reason has no effect on step 8: every 32 byte entry counts as a revocation of the identity, so that the check fails safe. Identity entries of every cached SRL count at step 8, not only those of the named identity's own issuers, because no issuer is trusted by the protocol and a verifier that has an entry cannot know that a better informed party exists. **Loading an SRL.** To load an SRL a verifier first checks whether its cache already holds exactly these bytes. If it does, the load is accepted, changes nothing and nothing below is performed (a MUST since Draft 05: a list that names its own issuer from an instant at or before its own `issued-at` would otherwise fail step 8 the second time it is loaded into a context that holds it, section 7). Otherwise it performs, in this order: steps 1 to 8 of section 10 on the envelope, in the verifier's own context (cached SRLs, directly supplied revocations and the local attestation store), so that an issuer that is already revoked or retired as of the list's `issued-at` cannot publish it (a failure is reported as it is, for example `mldsa_signature_invalid` at step 4 or `signer_revoked` at step 8); the content type MUST be `application/atep-srl+cbor` (`srl_wrong_content_type`, step 9); the payload MUST pass the schema (`srl_schema_invalid`); `issuer` MUST equal the signer (`srl_issuer_mismatch`); then the sequence rule against the cache, which holds at most one list per issuer, the one with the highest sequence. A lower `sequence` than the cached one is a rollback (`srl_rollback`); the same `sequence` with different bytes is a conflict (`srl_sequence_conflict`); a higher `sequence` replaces the cached list (the same bytes again were handled first, above). A list that is already stale when it is loaded is accepted and stored; its staleness is judged when it is used. Where a verification request supplies SRLs to be loaded (as the test vectors do), they are loaded in the order given, each in the context built from the lists loaded before it, the directly supplied revocations and the local attestation store, so that step 8 of a later list sees an identity entry of an earlier one (RT24). A list that fails to load is a configuration error with no defined verification result; implementations SHOULD fail the verification with the load error, at step 9 (section 20). **Freshness.** An SRL is stale when `now >= next-update`; the boundary counts as stale, which fails safe like step 8. When step 9 needs the SRL of an attestation's issuer, the cached list of that issuer is the only candidate and the verifier applies its policy: | Situation | `fail-closed` | `fail-open` | | --- | --- | --- | | The issuer's list is stale | reject with `srl_stale` | use the stale list, add the warning below | | No list is cached for the issuer | reject with `srl_unavailable` | proceed with no list, add the warning below | The defaults are fail-closed for a stale list and fail-open with a warning for a missing one, so that a fresh verifier that has not fetched anything yet still works; high-assurance contexts set both to fail-closed, and ATEP-R forces fail-closed for the dangerous command classes (section 17). A stale list that is used still revokes everything it names, and its identity entries are consulted at step 8 whatever its freshness: a revocation is never forgotten. **Warnings.** A warning is text, and warnings are compared verbatim by the test vectors, so the two templates are normative. `` is the issuer's Agent ID in its `atep:` text form and `` the decimal `next-update`: - `no SRL cached for issuer ; revocation status unknown` - `SRL of is past next-update ; using the stale copy` A result lists each distinct warning once, in the order in which step 9 first encountered it (claim attestation first, then its authority chain upward; under succession the successor attestation before the claim attestation, section 7; the rules of the policy before the class requirement of ATEP-R), and omits the list when it is empty. A warning met while validating an attempt that failed is still in the order and in the list when a later attempt of the same rule succeeds. **Verifier duties.** A verifier MUST fetch each issuer's SRL at least once per `next-update` interval (recommended 24 hours) and MUST cache it. If the SRL is unreachable and the cached copy is past `next-update`, the verifier applies the policy above: fail-closed for high-assurance contexts, fail-open with a warning for low-risk ones. The protocol does not force one choice. **Why not online status checks.** Per-verification queries (the OCSP model) leak every verification to a server, add latency, and fail when the server is down. SRLs plus short expiry give equivalent freshness in practice with none of those costs. ## 9. Transparency log and registry The registry is an append-only public log, not an authority. Issuers submit every attestation and SRL to it; anyone can audit it; nobody, including the operator, can alter or back-date an entry. This is the Certificate Transparency model applied to agent certification, and it is what makes an issuer's "ethical" claims accountable rather than declarative. **Log structure.** A Merkle tree exactly as in RFC 9162 section 2.1: the leaf hash is `SHA-256(0x00 || leaf)`, the node hash is `SHA-256(0x01 || left || right)`, the hash of the empty tree is `SHA-256` of the empty string, and a tree of n > 1 leaves splits at the largest power of two strictly smaller than n. Each leaf is the *submitted form* of an attestation or SRL (below). The leaf hash is therefore `SHA-256(0x00 || submitted form)`, not the bare SHA-256 of the envelope; Draft 02 said otherwise and the test vectors have always used the RFC 9162 form. **Submitted form.** The submitted form of an envelope is its encoding with the entry `-70012` removed from the top-level unprotected header map, everything else unchanged: the map's length head is re-encoded in shortest form, the remaining entries and their order stay, and nested envelopes (inline attestations under `-70009`) are not touched. An envelope that has no `-70012` entry is its own submitted form. This is what lets an attestation embed the proof of its own inclusion: the proof is not part of the logged bytes. **Checkpoints.** The log signs a checkpoint with its own hybrid identity. A checkpoint is an ATEP envelope with content type `application/atep-checkpoint+cbor`, no `expires-at`, `issued-at` equal to the checkpoint `timestamp`, and the signer bundle inline. Its payload is a deterministic CBOR map with exactly the text keys `tree-size` (unsigned integer), `root-hash` (32 bytes) and `timestamp` (unsigned integer); an unknown or missing key is a schema failure. Checkpoint timestamps never decrease. To *check* a checkpoint a verifier, in this order: verifies the envelope with steps 1 to 8 (a failure is reported as `inclusion_proof_invalid` at step 9 with `cause {step, error}`); requires the content type (`inclusion_proof_invalid`); requires that the signer is in its `trusted_logs` (`checkpoint_untrusted`); and validates the payload schema (`checkpoint_schema_invalid`). The result names `{log, tree_size, root_hash, timestamp}`, with `log` the signer's Agent ID text. **Checkpoint hash.** The checkpoint hash of a checkpoint is `SHA-256` of its payload bytes: the deterministic CBOR map `{tree-size, root-hash, timestamp}` exactly as carried in the signed envelope (the bytes the signature covers through the payload, not the envelope). It is 32 bytes, written as lowercase hex in JSON. It is a function of the checkpoint contents only: two envelopes with the same payload have the same checkpoint hash even when their signatures differ (hedged signing, section 6). A verifier that has verified a checkpoint envelope (steps 1 to 8, content type, schema) computes the hash from the payload it verified. This is the value an anchor commits to. Defining it does not oblige any log to anchor. The `checkpoint-hash` vectors pin it, including the empty tree (size 0, the hash of the empty string as root), a tree size that needs an eight byte integer head, and two envelopes of one payload with different signatures; a checkpoint that is rejected yields no hash. **Anchor records (optional).** A log MAY witness its checkpoints on an external witness (a public blockchain, a timestamping service or a public append-only log) by writing the checkpoint hash there. For each such anchoring the log MAY publish an *anchor record*, evidence that the hash was written. A log that does not anchor publishes none; the list of anchor records of every checkpoint is then empty, and such a log is fully conformant. Anchoring is never a source of trust: a verifier that ignores anchors loses nothing it had, an unavailable or compromised witness changes no verification result of this specification, and no envelope, attestation, SRL, identity or key ever touches a chain. An anchor record is a deterministic CBOR map with the text keys: | Key | Type | Content | | --- | --- | --- | | `checkpoint-hash` | bytes(32) | the checkpoint hash that was anchored | | `chain-id` | text | the witness, from the registry below or an extension id | | `transaction-id` | text | the transaction or proof identifier, as the witness prints it; 1 to 512 bytes | | `block-height` | unsigned integer | block height of the anchor, 0 to 2^63-1. OPTIONAL: omitted for witnesses that have no height (for example `rekor`) | | `anchored-at` | unsigned integer | Unix seconds, the witness's time for the anchor, 0 to 2^63-1 | The map has no other keys; an unknown key, a missing REQUIRED key or a `chain-id` that is neither registered nor a well formed extension id makes the record invalid. *Validity of a record, exactly.* The bytes of a record are one strict deterministic CBOR item (section 5: no trailing byte, no duplicate or unsorted key, shortest heads, definite lengths), and that item is a map whose keys are exactly the text keys of the table (an integer key, a key not in the table or a missing REQUIRED key makes the record invalid). `checkpoint-hash` is a byte string of 32 bytes. `transaction-id` is text of 1 to 512 bytes of UTF-8. The two integers are unsigned integers in the range 0 to 2^63-1: ATEP CBOR integers fit a signed 64-bit range (section 5), so a head above 2^63-1 fails to decode, a CBOR bignum is not an integer here because no tag other than 98 and 96 is accepted, and null, text and negative values are invalid (a `block-height` that is present as null is invalid; an absent key is how a record says there is no height). A record is encoded with no `block-height` key when there is none, never with null or 0, and a JSON view or a vector result writes the absent height as `null`. A record that fails any of this is `anchor_record_invalid` where a record is decoded on its own (the `anchor-record` vectors) and `anchor_schema_invalid` at step 9 of the check of a published anchor (below). Decoding and encoding both apply: building the record from a decoded one and encoding it deterministically gives back the bytes. The record is *signed by the log*: it is the payload of an ATEP envelope with content type `application/atep-anchor+cbor`, signed by the log's identity with the signer bundle inline, `issued-at` the time the log recorded the anchor and no `expires-at`, verified by steps 1 to 8 like a checkpoint. A verifier takes an anchor record as published only when the checks of "Checking a published anchor" (below) pass. A log MUST NOT publish an anchor for a checkpoint it did not sign, nor two records with the same `checkpoint-hash`, `chain-id` and `transaction-id`. Anchor records are not log entries: they are not leaves, they do not change the tree and they do not change any checkpoint. Whether the chain really holds the hash is checked by the verifier or monitor against the chain, through its own infrastructure, never from a robot; this specification does not define that check (milestone M5, section 13). ``` anchor-record = { "checkpoint-hash" : bstr .size 32, "chain-id" : chain-id, "transaction-id" : tstr .size (1..512), ? "block-height" : uint, ; 0 to 2^63-1 (section 5, integers) "anchored-at" : uint, ; 0 to 2^63-1 } chain-id = "solana-mainnet" / "ethereum-mainnet" / "bitcoin-mainnet" / "opentimestamps" / "rekor" / extension-chain-id extension-chain-id = tstr .regexp "x-[a-z0-9]([a-z0-9-]{0,60}[a-z0-9])?" ; x- plus 1 to 62 characters ``` **Checking a published anchor.** A verifier, monitor or client that takes an anchor record as published for a log L and a checkpoint whose checkpoint hash is H performs these checks in this order, and the first failure is the result: 1. Steps 1 to 8 of section 10 on the envelope, with no recipient identity, no trust policy and replay checking off (a monitor reads the same anchor many times; the signer bundle is inline). A failure is reported as it is, with its own step and code (for example `4/eddsa_signature_invalid`, `4/mldsa_signature_invalid`, `5/issued_in_future`); it is not wrapped, unlike the failure of a checkpoint inside an inclusion proof. 2. The content type is `application/atep-anchor+cbor`, else `anchor_content_type_invalid`. 3. The payload is a valid anchor record (above), else `anchor_schema_invalid`. 4. The signer is L, else `anchor_log_mismatch`. A log argument that is not an Agent ID names no signer and gives the same result (the Python implementation; the Rust API takes a parsed Agent ID and the npm wrapper throws, so there the case is a caller error and not a result). 5. The `checkpoint-hash` of the record equals H, else `anchor_checkpoint_mismatch`. The four codes are step 9 rejections and are in the error table of section 10. On success the result is `{ok: true, log, record}` with `log` in the `atep:` form. No verifier is obliged to evaluate anchors (decision 61); the names matter to a log, a monitor or a client that shows anchors. Whether the witness really holds the hash is not checked here (milestone M5), and the `anchor-envelope` vectors (13) pin everything above except the two points that follow, which this draft decides although no vector covers them (section 12, known gap 25). First, an anchor envelope is never encrypted: an encrypted one (tag 96) is rejected, and since the check uses no recipient identity the rejection is the one steps 1 to 8 give, `2/no_recipient_key` (the Rust and npm implementations; the Python implementation reports `1/unexpected_encryption`, which is a code follow-up of section 20). The same holds for a checkpoint and an SRL taken as published. Second, `expires-at` on an anchor record is neither required nor forbidden to a verifier: it is compared at step 5 like for any envelope, so an expired one fails `5/expired` and one in the future is otherwise ignored; a log signs none, as stated above. **The `chain-id` registry.** The `chain-id` of an anchor record and of a `require_anchor` rule names the witness. Draft 04 registers: | `chain-id` | Witness | | --- | --- | | `solana-mainnet` | Solana mainnet-beta | | `ethereum-mainnet` | Ethereum mainnet | | `bitcoin-mainnet` | Bitcoin mainnet, direct transaction | | `opentimestamps` | Bitcoin via an OpenTimestamps calendar (aggregated) | | `rekor` | Sigstore Rekor public append-only log (not a chain) | The five ids of the table are the registered ids, matched as whole text and exactly: another case, a prefix or a suffix, a trailing space or newline and a near miss (`solana`, `Solana-mainnet`, `rekor2`, `bitcoin-testnet`) are not registered, and no registered id starts with `x-`. The sentence of earlier drafts that described a registered id as "1 to 64 bytes of lowercase letters, digits and hyphens" described the table and is not a second rule that accepts other texts. *Extension rule.* An id of the form `x-` is valid without registration, where `` is one or more of the lowercase letters `a` to `z`, digits and hyphens, does not start or end with a hyphen, and the whole id is at most 64 bytes, counted as UTF-8 bytes (so `x-` plus 62 characters is the longest valid id, and a non ASCII letter is invalid anyway). The rule is applied to the whole text, so a trailing newline fails. `x-`, `x--`, `x-acme-`, `x--acme`, `x-Acme`, `X-acme`, `x_acme`, `xacme`, `x-acme.ledger`, a name with a space, and the empty string are invalid. The `x-` namespace is reserved for private and experimental witnesses and is never assigned by the registry. Any other id that is not in the table is invalid: a parser MUST reject it (a record is invalid, a policy rule is a configuration error). The decision is a pure function of the text, with no registry lookup, and the CDDL above and in `spec/schemas/atep.cddl` says the same thing (the five registered ids as a choice of text constants and `extension-chain-id`); the `chain-id` vectors (30) pin it. Adding a registered chain is one new row in this table and needs no other change to the specification. Whether to rely on a witness, registered or not, is the verifier's decision. **Witnesses.** A log implementation that supports anchoring does so through a *witness*, one component with one operation (informative for other logs): ``` Witness.anchor(checkpoint_hash: bytes(32)) -> AnchorRecord | WitnessError ``` The witness writes the hash to its witness (or calls the service that does) and returns the unsigned anchor record (the map above); the log verifies that the returned `checkpoint-hash` equals the argument, signs the record as above, stores it and serves it. `WitnessError` has two cases: `Disabled` (no witness is configured; not a failure) and `Failed` (the witness could not anchor now; the log is unaffected and tries again at the next checkpoint). The no-op witness always answers `Disabled` and is the default: an operator chooses to anchor, and on which witness, by configuring one. Chain adapters (milestone M5) are later implementations of this one operation. An adapter has no access to keys, entries or envelopes, only to the 32 byte hash. **Cadence and freshness.** A log signs a fresh checkpoint at least every checkpoint interval (recommended one hour) even when the tree did not change, and the timestamp is the freshness signal. It also signs on demand, and it answers a submission with a checkpoint that covers the new entry: either one checkpoint per submission or a merge delay published in the log policy. Draft 03 defines no maximum checkpoint age for verifiers; a verifier MAY apply its own limit to the `timestamp` of the checkpoint it relies on. **Submission and the inclusion proof.** On issuing an attestation or SRL, an issuer POSTs it to the log and receives a Signed Inclusion Proof, the value that goes under label `-70012` in the unprotected header (section 5): a deterministic CBOR map with exactly the text keys `leaf-index` (unsigned integer), `audit-path` (an array of 32 byte hashes, RFC 9162 section 2.1.3.1) and `checkpoint` (the signed checkpoint envelope, embedded as a CBOR value, not a byte string, covering the leaf). Issuers SHOULD embed the proof in the attestation's unprotected header so verifiers can check inclusion offline. **Checking an inclusion proof.** Given the submitted form of an attestation, the verifier: requires the `-70012` entry (`inclusion_proof_missing`); requires that it parses as the map above (`inclusion_proof_invalid`); checks the embedded checkpoint as above, whose own errors are returned unchanged; and checks that the audit path leads from `SHA-256(0x00 || submitted form)` at `leaf-index` to the checkpoint's `root-hash` for its `tree-size`, by RFC 9162 section 2.1.3.2, requiring `leaf-index < tree-size`, a path of exactly the right length, and the final node computation to end at the root (`inclusion_proof_invalid`). When a policy requires inclusion (`require_inclusion`, section 7), every attestation in the chain needs a valid proof, a checkpoint is trusted when its signer is in `trusted_logs`, and the result reports the checkpoint with the latest `timestamp` (the larger `tree_size` breaks a tie). An online checker MAY replace the offline one, for example by asking the log for the entry. **Consistency proofs.** A consistency proof is a deterministic CBOR map with the text keys `from` and `to` (tree sizes, unsigned integers) and `path` (an array of 32 byte hashes, RFC 9162 section 2.1.4.1). It is verified by RFC 9162 section 2.1.4.2 with three additions: `from > to` fails; equal sizes need an empty path and equal roots; a `from` of 0 needs an empty path and a first root equal to the empty tree hash. A consistency check takes two checkpoints `old` and `new` and the proof, as the CBOR map `{old, new, proof}` with the checkpoint envelopes embedded as CBOR values. In this order the verifier: checks both checkpoints as above (including trust in the signer); requires the same signer; requires `new.tree-size >= old.tree-size`; requires `proof.from == old.tree-size` and `proof.to == new.tree-size`; and verifies the proof from `old.root-hash` to `new.root-hash`. A failure of the last four is `consistency_proof_invalid` at step 9. **Split views.** Two valid signed checkpoints of one log are a split view when they have the same tree size and different roots, or different sizes and a failing consistency proof between them. A split view check takes the CBOR map `{a, b, proof?}` of two checkpoint envelopes claimed to come from one log and an optional proof. In this order the verifier: checks both checkpoints as above, with `consistency_proof_invalid` for a different signer; for equal sizes, accepts equal roots and reports different roots as `split_view_detected`; for different sizes, requires the proof (`consistency_proof_invalid` when it is missing or when its `from` and `to` do not equal the smaller and larger tree size) and reports a proof that does not verify as `split_view_detected`, because two valid signatures of one log then cover histories that cannot both be append-only extensions of each other. Both checkpoints together are transferable evidence that needs only the log's public key: a party holding it can confirm the finding without trusting whoever reported it. A proof between checkpoints of different sizes that is merely missing is a gap, not a split view. **Admission.** A log accepts a submission only when all of these hold, and refuses it with the listed reason otherwise. The reasons are stable names; the HTTP form is under "Log API" below. | Rule | Refusal | | --- | --- | | The submission is at most the policy's `max-envelope-bytes` | `too_large` | | It is strict deterministic CBOR and a tag 98 or tag 96 item | `malformed` | | It is not encrypted: envelopes exchanged between agents are never logged (section 16 point 5) | `encrypted_envelope` | | Its content type is the attestation or the SRL type; a checkpoint is `content_type_not_loggable` (the log writes those itself) and anything else is a data envelope | `content_type_not_loggable`, `data_envelope` | | The submitted form passes steps 1 to 8 at the log's clock, with the signer bundle inline or already logged, and with the identity entries of the SRLs already in the log as the revocations of step 8 and the `retired` attestations already in the log as the local attestation store (section 7) | `verification_failed` (with the failing `step` and rejection) | | The payload matches the schema of its type and claim rules (section 7), and the attestation `issuer` or SRL `issuer` equals the signer | `schema_invalid` | | An attestation lives at most 400 days (the 180 day tier is not enforced, section 7) | `lifetime_exceeded` | | A claim type under `https://atep.dev/claims/` belongs to the core vocabulary; only the log may issue a log policy attestation | `claim_vocabulary` | | `domain-control` has `data.domain`, a canonical lowercase DNS name (section 7) | `schema_invalid` | | `registry-endpoint` has `data.url` and `data.kind` as section 7 defines them | `schema_invalid` | | `retired` has `subject` equal to `issuer`, `successor` has `subject` different from `issuer`, and in both `data.reason`, when present, is text (section 7) | `schema_invalid` | | An SRL has a `sequence` strictly higher than the highest logged for its issuer | `srl_rollback` | *The content type rule reads the protected header.* The content type is read from the protected header of an envelope that decodes as a tag 98 structure (the structural checks of step 1), before steps 1 to 8 are applied to the submitted form: a checkpoint is `content_type_not_loggable`, any type other than the attestation and SRL types is `data_envelope`, so a bare data envelope, which step 1 would reject as `unencrypted_non_trust_document`, is `data_envelope` and not `verification_failed`. An envelope that does not decode as that structure is `verification_failed` with its step 1 code. The Python implementation, which has no log, reads the header after steps 1 to 8 reject the submission; the two orders give the same refusal for every vector, and only an envelope that is malformed at step 1 and also has a non loggable content type could tell them apart (section 12, known gap 27). The claim vocabulary rule admits the 14 core claim types of section 7 and still refuses every other claim type under `https://atep.dev/claims/`. A log accepts a `registry-endpoint` attestation exactly as it accepts any attestation, so an agent card digest and the service URL are logged without any new document type or endpoint. A second `retired` attestation of an identity that has already retired is admitted like the first (section 7, the exemption of step 8), while any other document from that identity issued at or after its retirement is refused as `verification_failed` at step 8. That includes a `retired` attestation whose `subject` is not its issuer: the exemption covers only a `retired` attestation of the signer for the signer, so a log that holds a retirement of the issuer refuses it at step 8 when it is issued at or after that retirement, and reaches the schema rule (`schema_invalid`) only when it is not (RT31). A resubmission is idempotent: it is recognized by its submitted form before any validation (so a document that has expired since is still answered), returns the existing entry with a proof against a checkpoint that covers it, and is not logged twice. The log does not judge issuer authority, because no root is mandatory (section 16 point 3): monitors do. A subject that is a natural person (section 16 point 1) cannot be detected from the payload; this is operator policy, enforced through the closed core vocabulary and review. The local attestation store of a log is the `retired` attestations it holds, in submitted form; it is rebuilt in log order when the log restarts, each entry re-validated with the store as it was. A log re-validates every entry as at its original logging time when it restarts, and it stores a `logged-at` time per entry that is informational and not part of the leaf, so only checkpoint timestamps are committed. **The log policy entry.** Section 9 of Draft 02 said the log policy is recorded in the log without saying how. It is an attestation issued by the log to itself (`subject` and `issuer` are the log's Agent ID) with claim type `https://atep.dev/log/policy` (provisional, deliberately outside the closed core namespace so that no new media type or core claim is needed) and the policy in `data`. It is the first entry of a new log, has a lifetime of 365 days, and is re-issued before it lapses or when the configuration changes; the latest policy entry governs. The `data` fields are: | Field | Type | Content | | --- | --- | --- | | `version` | integer | `1` | | `log` | text | the log's Agent ID | | `operator` | text | operator name | | `admission` | array of text | the admission rules above, in the operator's words | | `retention` | text | retention statement | | `availability` | text | availability statement | | `checkpoint-interval-seconds` | integer | checkpoint cadence | | `checkpoint-media-type` | text | `application/atep-checkpoint+cbor` | | `max-envelope-bytes` | integer | admission size limit | | `key-custody` | text | key custody statement | | `core-namespace` | text | `https://atep.dev/claims/` | | `core-claims` | array of URI | the 14 core claim types | | `commitments` | array of text | the three commitments of "First log operator" | The field set is normative; the free text values are the operator's. Only the log itself may issue a claim of this type, and the log refuses it from anyone else (`claim_vocabulary`). The policy entry is unchanged in the structure it had in Draft 03; a log that anchors publishes its anchoring policy (witness, account or program address, cadence, how to locate anchors) as a later extension of it (milestone M5). *Transition.* A log that implemented the `registry-endpoint` proposal before Draft 04 admitted it as an extension claim: it listed it in an `extension-claims` member of `data` (an array of URI, outside the normative field set, which allows other members), left `core-claims` at the 13 claim types of Draft 03 and marked it `proposed` in the claim-type directory. Moving it into `core-claims` re-issues the policy entry and is the only change a log needs to be fully Draft 04. The reference log has made that move: its policy entry lists the 14 claim types in `core-claims` and has no `extension-claims`. **Monitors.** Any party can run a monitor that follows the log and alerts on anomalies. Monitors are how fraud is caught. A monitor is configured with the Agent ID of the log it follows (pinning it; trust on first use is an operator choice), the DNS names it watches, the Agent IDs it authorizes for them, and the roots it uses to judge delegation. Its checks and alerts are exact: | Alert | Raised when | | --- | --- | | `unauthorized_domain_control` | A logged `domain-control` attestation names a domain equal to a watched name or below it (a monitor watching `example.com` also watches every subdomain; names compare lowercase without a trailing dot) and its `subject` is not among the Agent IDs the monitor was told to authorize. Whether the issuer really checked the DNS or well-known record cannot be decided from the log; it is the monitor owner's reason to alert | | `issuer_outside_authority` | An issuer issues claim C at time T and is neither a root of the monitor nor holds, at T, a *live* `issuer-authority` attestation that lists C and whose own issuer is authorized for `issuer-authority` and for C (the rules of section 7, chain depth counted as there). An `issuer-authority` attestation that lists claims its issuer does not hold, or the `issuer-authority` URI without holding it, is the same finding. T is the `issued-at` of the entry judged. An attestation is live at T when its `issued-at <= T < expires-at` and no logged SRL of its issuer names it with `revoked-at <= T`. Delegations count only when they are in the log when the monitor analyzes the entry: one logged later does not clear an earlier alert | | `undelegated_issuer` | Strict mode only: an issuer that is not a root and has never been the subject of an `issuer-authority` attestation in the log issues anything. Without strict mode such an issuer is outside the delegation system and is not judged. The log's own policy entry is never judged | | `successor_chain` | A logged `successor` attestation has an `issuer` that is itself the `subject` of another logged `successor` attestation, so succession spans two or more hops (section 7). Carries `entry`, the index of the link whose `issuer` is the `subject` of another logged `successor` attestation (the later link of the chain, whatever the order in which the two were logged), and that link's `issuer` and `subject`; raised once per entry. A log's own key rotation counts the same way (section 11). A single hop is not an alert, and neither is a fork, one identity naming two successors (section 14) | | `inconsistent_checkpoint` | A later checkpoint's consistency proof from an earlier one fails. Carries evidence | | `checkpoint_gap` | A published checkpoint pair has no consistency proof available, or the log cannot list its published checkpoints | | `tree_shrank` | A later checkpoint, or the entries served, cover fewer entries than an earlier one | | `entry_root_mismatch` | The entries the log serves for a tree size it signed do not hash to the signed root | | `entry_gap` | The log does not return entries it committed to for a tree size it signed | | `entry_invalid` | A logged entry fails verification as of its own `issued-at`, or does not parse as an attestation or SRL | | `bad_checkpoint` | A checkpoint fails verification, or is signed by a log other than the pinned one | | `split_view` | Two valid signed checkpoints of one log have the same size and different roots, or fail a consistency proof (sizes differ). Carries evidence | | `source_unavailable` | The log cannot be reached | An alert has a stable `alert` type name and the fields it needs: `entry` for the entry index, `issuer`, `subject`, `claim`, `domain`, `watched-domain`, `from`, `to`, `tree-size`, `log`, `reason` and `detail` as applicable, and `evidence`, the CBOR `{a, b, proof?}` document, as unpadded base64url. A monitor reports each finding once. **Anchors in the monitor API.** Wherever a monitor interface returns a log's checkpoints it also returns, for each checkpoint, the checkpoint hash and the list of its anchor records (empty when the log does not anchor), as in the Log API below. A monitor MUST verify each anchor record's envelope as a published anchor (signed by the followed log, hash equal to the checkpoint's), with the checks and codes of "Checking a published anchor" above. Checking the anchor against the witness, and alerting when an anchor is missing or contradicts the log, is defined with milestone M5; Draft 04 adds no anchor alert type. Monitors written against this interface show anchors as soon as a log publishes them. **Registry services** (layered on the log, all optional for the protocol). They are *derived data*: anyone can recompute them from the log entries and the log policy, and they carry no authority. They exist for convenience and are not a source of trust. - *Issuer directory.* One row per identity that issued a logged document: `issuer`; `first-index` (its first entry); `entries` (count); `claims` (claim types it issued); `namespaces` (each claim URI up to and including its last `/`); `delegated-claims` (claim types delegated to it) and `delegated-by` (who delegated them); `domains` (names bound to it by `domain-control` attestations it holds about itself); `srl-urls` (for each such domain, `https:///.well-known/atep-revocations.cbor`); `latest-srl` (`entry`, `sequence`, `issued-at`); and `included` (true). - *Claim-type directory.* One row per claim type seen in the log plus every core claim type: `claim`, `core` (true for the core vocabulary), `namespace`, `entries`, `issuers` (count), `first-index`, `status` (`core`; `proposed` for a proposal the log admits, such as `registry-endpoint` before the log moves it into its core list; `open` for any other claim type seen in the log), and for every claim type the registry defines a `definition` text, a `data-schema` text (CDDL) and `resolve` (the path of its resolver, below); other claim types appear with these three set to `null`. The log policy entry is not counted. - *Lookup.* Attestations whose `subject` is a given Agent ID, optionally filtered by claim type. Because lookup is a query by subject, it MUST serve attestations and SRLs only (never agent traffic, section 16 point 5), and operators SHOULD rate limit it. - *Gossip.* Checkpoint exchange, below. **Gossip.** Any node (a log or a monitor) may hand any other node signed checkpoints, its own and third-party ones it has seen. The receiver verifies each one (steps 1 to 8, content type and schema; whether to trust the signer is its own decision), compares it with every checkpoint it holds for the same signing log, keeps the new ones, and produces split view evidence when it finds a contradiction: a stored checkpoint of the same size with a different root, or, for the nearest smaller and the nearest larger stored size, a consistency proof (supplied with the message or obtained from the log in question) that fails. Relaying third-party checkpoints is what lets two viewers of one equivocating log find out, because neither sees both views directly. Observed checkpoints and evidence SHOULD be persisted. A node that holds evidence answers with it and with an HTTP 409 where the exchange is over HTTP. **Log API and transport.** The API is HTTP with JSON, plus CBOR responses where one exists (`Accept: application/cbor`, checkpoints as `application/atep-checkpoint+cbor`). Transport security (TLS) is an operational layer and carries no trust, because all data is signed; a public log runs behind a TLS terminating proxy. Binary values in JSON are unpadded base64url, hashes and ids are lowercase hex, and Agent IDs are `atep:` text. Errors are `{"error": "", "detail": ""}` with an HTTP status. The endpoints, provisional in Draft 04, are: | Endpoint | Purpose | | --- | --- | | `POST /v1/submit` | Body is the envelope (`application/cbor`) or JSON `{"envelope": }`. `201` accepted, `200` duplicate; reply `{status, leaf-index, leaf-hash, audit-path, proof}` where `proof` is the `-70012` value; refusals are `400` (`malformed`), `413` (`too_large`) and `422` with the admission reason | | `GET /v1/checkpoint`, `POST /v1/checkpoint` | The latest signed checkpoint (signing a fresh one first if the interval has passed); `POST` signs one on demand | | `GET /v1/checkpoints?from=` | Every published checkpoint of at least that size, oldest first, at most 1000 | | `GET /v1/proof/inclusion?leaf-hash=` (or `index=`) | Inclusion proof against a checkpoint that covers the leaf; `404` if the leaf is not logged | | `GET /v1/proof/consistency?from=&to=` | Consistency proof for `m <= n <= tree size` | | `GET /v1/entries?from=&to=` | Entries `i` to `j` (exclusive, at most 1000) with the submitted form, so the leaf hash is SHA-256 of 0x00 followed by the envelope | | `GET /v1/lookup?subject=[&claim=]` | Lookup | | `GET /v1/issuers[?issuer=]`, `GET /v1/claims` | The two directories | | `GET /v1/policy` | The log policy entry with its inclusion proof | | `GET /v1/gossip`, `POST /v1/gossip` | Checkpoint exchange; `POST` takes `{"checkpoints": [, ...], "proofs": [, ...]}` and answers with the receiver's own and relayed checkpoints, with `409` when a split view was found and the evidence in the result | | `GET /v1/claims/` | Definition and schema of one claim type. `` is the claim URI, percent-encoded (`https%3A%2F%2Fatep.dev%2Fclaims%2Faudited`; the plain form is accepted too), or a short name (section 7). JSON by default, HTML with `Accept: text/html` or a `.html` suffix. `404` `claim_unknown` for any claim type the registry has no definition for, including claim types of other namespaces | | `GET /claims`, `GET /claims/`, `GET /claims/robotics/` | The same documents at the path of the claim URI, so a registry deployed at `atep.dev` serves `https://atep.dev/claims/` itself. `GET /claims` is the index | | `GET /openapi.json` | The OpenAPI 3.1 description of every endpoint of the registry, with the request, response and error shapes in JSON and the CBOR content types | | `GET /` | Service index: `{service, log, openapi, endpoints}`, the Agent ID of the log, the location of the OpenAPI document and the list of routes | **Checkpoint objects in the API.** The JSON reply of `GET /v1/checkpoint` and `POST /v1/checkpoint`, each element of `checkpoints` in the reply `{log, checkpoints}` of `GET /v1/checkpoints`, and the `latest` member of `GET /v1/gossip` are objects with `log` (Agent ID text), `tree-size`, `root-hash` (lowercase hex), `timestamp`, `checkpoint` (the signed envelope as unpadded base64url) and, new in Draft 04, two members: | Member | Content | | --- | --- | | `checkpoint-hash` | lowercase hex of the checkpoint hash | | `anchors` | array, possibly empty, of `{checkpoint-hash, chain-id, transaction-id, block-height, anchored-at, anchor}`; `block-height` is `null` when absent and `anchor` is the unpadded base64url log-signed envelope the other members were read from | A client MUST ignore members it does not know, so Draft 03 clients are unaffected. The CBOR response (`Accept: application/cbor`) is the checkpoint envelope and is unchanged. **Claim-type resolution.** Two route families serve the same documents. `GET /v1/claims/` takes the claim URI percent-encoded (the plain URI is accepted too: the rest of the path is the parameter) or a short name (section 7). `GET /claims`, `GET /claims/` and `GET /claims/robotics/` are at the path of the claim URI, so that a registry deployed at `atep.dev` answers `https://atep.dev/claims/audited` and `https://atep.dev/claims/robotics/fleet-member` itself; `GET /claims` is the index, `{namespace, claims: [{claim, name, core, status, profile, definition, title, url}]}`. Only `/claims/robotics/` serves a robotics claim: `/claims/fleet-member` is not found. A trailing slash on a `/claims` path is ignored. A name or claim ending in `.html` selects HTML and one ending in `.json` selects JSON; otherwise HTML is served only when the `Accept` header prefers `text/html` to `application/json` by quality value (a browser sends `text/html` at 1 and `*/*` at 0.8), and no `Accept`, `*/*` and ties give JSON. A definition is cacheable: `Cache-Control: public, max-age=3600` and `Vary: Accept`. A claim type with no definition is `404` with `{"error": "claim_unknown"}`, for example a claim type of another namespace or the log policy URI. A registry defines the 14 core claim types of section 7 and the proposals it carries (`status` `proposed`). `HEAD` is answered for every `GET`, and every response carries `Access-Control-Allow-Origin: *`. The document of a claim type has the members `claim`, `name`, `core`, `status` (`core` or `proposed`), `profile` (`core`, `robotics` or `draft-04`), `definition` (a one sentence text), `title`, `description` (an array of paragraphs), `issued-by`, `subject`, `data-schema` (CDDL text), `data-schema-format` (`cddl`), `attestation-schema` (CDDL text of the whole attestation payload), `data-checked-by`, `evidence`, `lifetime`, `spec` (an array of section references), `example-data` and `links` (`self`, `html`, `api`, `directory`). A client MUST ignore members it does not know. The definitions come from one data file in the reference log that tests compare with the core claim list of `atep-core` and with `spec/schemas/atep.cddl`. **OpenAPI document.** `GET /openapi.json` returns an OpenAPI 3.1 description of every route of the log: path, method, parameters, request bodies (CBOR and JSON), response schemas (JSON and the CBOR content types) and the shapes of the errors, with component schemas for the JSON objects (`Checkpoint`, `Anchor`, `Entry`, `ClaimDefinition`, `ClaimRow`, `Rejection` and the rest). It is informative and derived: the reference log builds it from the same route table it dispatches on, so a route cannot exist without a description, and its tests validate live responses against the component schemas and compare the served document with a checked in copy (`rust/docs/openapi.json`). Where it and the prose or the CBOR documents of this section differ, the prose and the CBOR documents decide. Its `info.version` versions the API description and not the protocol (the reference value is `0.2.0`), `servers` lists the log itself and, for a deployment mounted there, `https://atep.dev`, and `security` is empty because every read is public. An agent that has only the URL of a log can read the description, look up an issuer and resolve a claim type without an SDK. **Log identity and key rotation.** A verifier learns a log's Agent ID out of band (published with the log policy, the root set or the software), exactly like roots; the log also publishes its bundle. A log key is rotated with a `successor` attestation issued by the old key (strictly before the old key retires, section 7) and logged, and monitors treat more than one hop as an alert (`successor_chain`, section 11). The attestation is evidence, not an automatic update of any `trusted_logs`: a verifier still learns the new log ID out of band. The reference log does not implement rotation yet (section 13). **First log operator.** AIRAD LABS operates the first log (log #1). It does so under a published log policy covering admission rules, retention, availability targets, checkpoint cadence and key custody, and that policy is itself recorded in the log. AIRAD LABS commits to three things from launch: publish the log's data and APIs so anyone can run an independent log or monitor; participate in checkpoint gossip with every independent log; and, at the earlier of the first independent implementation (section 13) or a second operator being ready, add a neutral or multi-party operator and move the recommended root set to a multi-log configuration. No verifier is required to trust log #1 (section 16). **Roots.** ATEP ships with no built-in roots. The reference verifier accepts a configurable root set; the registry operator publishes a recommended set, and enterprises add their own. Multiple logs and multiple root issuers are expected and designed for. Issuers vouch for agents and publish every claim to the log; verifiers decide using cached lists and checkpoints; monitors catch an issuer that strays. ## 10. Verification algorithm A conforming verifier MUST perform these steps in order and reject on the first failure. The reference implementation exposes this as a single `verify(envelope, policy, now)` call returning a structured result that names the step that failed. A rejection carries the step number (1 to 9, the part that is normative), a stable error code (below) and, for a step 9 failure that wraps a failure of steps 1 to 8 on an attestation, a `cause` `{step, error}`. All times are Unix seconds. 1. **Decode.** Parse the CBOR strictly (section 5, encoding rules) and confirm tag 98 or tag 96. The checks below run in the order listed and the first failure is reported. - *Tag 96.* Step 1 applies to the outer COSE\_Encrypt: it is a four element array whose protected header is a byte string holding a map; `atep-version` is present and equal to 1; `suite` is present and supported; the content algorithm (label 1) is A256GCM; the unprotected header is a map with a 12 byte `iv`; the ciphertext is a byte string; there is exactly one recipient, a three element array whose protected header has algorithm `-70011` (the ATEP-1 hybrid KEM), whose unprotected header is a map carrying a 32 byte `kid`, an ephemeral X25519 COSE\_Key in exactly the encoding of section 4, and a 1,088 byte `-70013` ML-KEM-768 ciphertext. A violation of any of these, including a wrongly sized ephemeral key or KEM ciphertext, is a step 1 failure, so that step 2 only ever sees a well-formed structure. - *Tag 98.* Step 1 applies to the signed envelope: it is a four element array with a byte string protected header; the protected header is a map in which, in this order, `atep-version` is present, an integer and 1, `suite` is present, text and supported (`ATEP-1`), the content type is present and text, `signer` is a 32 byte string, `issued-at` an integer, `nonce` 16 bytes, `payload-digest` 32 bytes, `expires-at` (if present) an integer and `command-class` (if present) text; the unprotected header is a map; the payload is a byte string or nil; the signatures are an array of three element entries each with a byte string protected header, a map and a byte string signature, and a protected header that decodes to a map with an integer `alg` and a byte string `kid`. Then the signature shape: exactly two entries, one EdDSA and one ML-DSA-65 and no other algorithm. Then `expires-at` present when the content type is the attestation media type. Then, when the envelope is not wrapped in tag 96, a trust document content type (the encryption rule of section 5). Then, only when the verifier treats the channel as ATEP-R (section 17), in this order: the envelope is encrypted, `command-class` is present, and its value is one of the seven classes. The ATEP-R checks come after every core check, and for tag 96 they apply to the inner envelope, so a bare ATEP-R envelope with a data content type reports the encryption rule first. - For tag 96 the inner COSE\_Sign is checked again after step 2 as the tag 98 case, and any failure there, including a plaintext that does not decode or is not tag 98, is still reported as step 1. Decode failures are not retried or repaired. 2. **Decrypt** (if tag 96). In this order: the verifier holds a recipient identity with encryption keys (`no_recipient_key`); the recipient `kid` equals the verifier's Agent ID (`not_addressed_to_recipient`); the X25519 shared secret is not all zero (`kem_failure`); the hybrid KEM decapsulation with the recipient's keys is performed, the AEAD key derived (section 6) and the ciphertext decrypted (`aead_failure`). ML-KEM decapsulation uses implicit rejection, so a corrupted KEM ciphertext surfaces as an AEAD failure. Any KEM or AEAD failure is fatal. The plaintext, which MUST be a tag 98 envelope, is decoded as in step 1. 3. **Resolve the signer.** Obtain the signer's public-key bundle: the inline `-70008` entry if the envelope has one, otherwise the verifier's cache entry whose Agent ID equals `signer`, otherwise reject (`signer_bundle_unavailable`). An inline bundle that is not the exact form of section 4 is rejected (`signer_bundle_invalid`). Recompute the Agent ID as SHA-256 of the bundle encoding and confirm it equals the `signer` header; reject on mismatch (`signer_id_mismatch`). Confirm that the `kid` of both signatures equals the `signer` header; reject on mismatch (`kid_mismatch`). 4. **Verify both signatures.** Obtain the payload: the attached bytes, or for a detached envelope the payload supplied out of band, rejecting here when none was supplied (`detached_payload_missing`). Check the EdDSA signature and then the ML-DSA-65 signature over the Sig\_structure of section 5 (`eddsa_signature_invalid`, `mldsa_signature_invalid`). Both MUST pass. 5. **Check time.** `issued-at` MUST be no more than the allowed skew (recommended and default 300 s, inclusive) in the future (`issued_in_future`); `expires-at`, if present, MUST be later than `now` (an envelope with `expires-at` equal to `now` is expired, `expired`). The presence of `expires-at` for attestations was already enforced at step 1; only its value is compared with `now` here. 6. **Check replay.** For contexts that require it, confirm the `nonce` has not been seen within the envelope's validity window (`nonce_replayed`). A verifier with no replay information skips this step; it is never applied to attestations. 7. **Check payload digest.** The SHA-256 of the payload (attached or supplied out of band) MUST equal `payload-digest` (`payload_digest_mismatch`). 8. **Check signer status.** The signer MUST NOT be revoked as of `issued-at`. It is revoked as of `issued-at` when any one of these holds: it appears as a 32 byte identity entry, whatever its `reason`, in any cached SRL (section 8) or in the directly supplied revocation entries, with `revoked-at` less than or equal to `issued-at`; or the verifier's local attestation store holds a valid retirement of it (section 7, "Retirement and succession") whose `issued-at` is less than or equal to this envelope's `issued-at`, unless this envelope is itself a `retired` attestation of the signer (decided on its shape alone, section 7). An envelope issued exactly at the revoking instant is rejected (`signer_revoked`). This applies to the data envelope and to every attestation verified at step 9, so an issuer compromised before it issued an attestation invalidates that attestation, while an attestation issued before `revoked-at` stays valid. 9. **Evaluate policy.** Skipped when the verifier has no trust policy. Otherwise a policy that contains a `require_anchor` rule is rejected first with `anchor_not_supported` (section 7), whatever the other rules would have done, and then the policy rules, and under ATEP-R the class requirement of section 17, are evaluated as specified under "Step 9 in detail" below. Every rule MUST be satisfied. 10. **Return.** A result containing: the signer's Agent ID, the set of verified claims with their issuers and expiry, the log checkpoint used, any warnings, and the payload. Callers MUST NOT act on the payload before this result is positive. Steps 1 through 8 need no network. Step 9 needs only cached issuer keys, SRLs and checkpoints, so a verifier that refreshes those daily can run fully offline, for as long as the caches it has filled stay within their `next-update` (section 8). The core verifier of steps 1 to 8 checks the content type label only: with no trust policy, a bare envelope under a trust document label whose payload is not a valid attestation, SRL or checkpoint still verifies, and it is for the component that interprets the payload to validate it (section 5). **Step 9 in detail.** Step 9 builds the attestation pool (section 7) and then evaluates *requirements*. Each requirement is a list of alternatives, and each alternative is a list of rules that must all hold. The rules of the policy are one requirement each with a single alternative, evaluated in order under the policy's SRL behavior; the ATEP-R class requirement, if any, follows as one more requirement with the SRL behavior of its class (section 17). A requirement is satisfied by the first alternative whose rules are all satisfied; if none is, the verification fails with the error of the alternative that satisfied the most rules before failing (a rule that fails, whatever the reason, including `claim_data_mismatch`, is not counted as satisfied; ties go to the first alternative). A rule is evaluated as follows: 1. If the rule names a root outside the root set, reject `policy_invalid`. The rule's roots are that root, or the whole set. 2. Take the claim attestation candidates for the signer and the rule's claim (section 7, candidate selection). None: `claim_missing`. 3. Validate each candidate in pool order and apply the rule to it: after candidate validation, `max_age_days` (`claim_too_old`), then the `data` condition of the rule if it has one (`claim_data_mismatch`; the only such condition in this specification is the `peer-motion` rule of section 17), then authorization of the candidate's issuer for the claim (`authorize` of section 7; the walk calls the same candidate validation for every delegation). The first candidate that passes everything satisfies the rule. If none does, the rule fails with the error of the first candidate. 4. *Succession, only when the policy sets `follow_succession`.* If the rule failed with `claim_missing`, apply the succession rule of section 7 ("`successor`: following succession"). If it finds a satisfying pair, the rule is satisfied; otherwise the rule fails with `claim_missing`, exactly as without the setting. A rule that failed with any other error is not retried. 5. A satisfied rule contributes a verified claim to the result. **Candidate validation.** Each pool candidate is verified completely, and memoized so that it is verified once per verification. The checks run in this order and the first failure is the candidate's error, and every one of them is a step 9 error except the first, whose failure is wrapped: 1. The attestation envelope is verified as an envelope with steps 1 to 8 at the same `now`, with the same bundles, revocations, SRL cache, skew and local attestation store (which step 8 reads for retirements and nothing else), with replay checking off, no recipient identity (so an encrypted attestation fails at step 2) and no trust policy (so there is no recursion into step 9). A failure is `attestation_invalid` with the inner `cause {step, error}`. 2. The content type MUST be the attestation type and the envelope unencrypted, else `attestation_schema_invalid`. 3. The payload MUST pass the schema (a payload that does not decode is `attestation_schema_invalid`) and the claim rules of section 7 (the evidence requirement and the layouts of `issuer-authority`, `retired` and `successor`), else `attestation_schema_invalid`. 4. `issuer` MUST equal the signer, else `attestation_issuer_mismatch`. 5. `expires-at` minus `issued-at` MUST NOT exceed 400 days, else `attestation_lifetime_exceeded`. 6. The issuer's SRL is looked up (section 8): a missing or stale list follows the SRL behavior in force (`srl_unavailable`, `srl_stale`, or a warning); an entry naming the attestation's `id` is `attestation_revoked`. 7. If the policy requires inclusion, the proof is checked (section 9): `inclusion_proof_missing`, `inclusion_proof_invalid`, `checkpoint_untrusted`, `checkpoint_schema_invalid`. **Error codes.** The step is the normative part of a rejection. The code names below are stable and implementations SHOULD use them; the test vectors compare them. The table is complete for the verifier and for the published anchor check of section 9 (the four `anchor_*` codes); the log and monitor have their own names in section 9. | Step | Code | Meaning | | --- | --- | --- | | 1 | `malformed_cbor` | Not strict deterministic CBOR, trailing bytes, or an inner envelope that does not decode | | 1 | `unexpected_tag` | Not tag 98 or 96 where one is required | | 1 | `malformed_structure` | Wrong array arity, wrong element types, malformed recipient, key or ciphertext sizes | | 1 | `missing_header` | A REQUIRED protected header (`atep-version`, `suite`, content type, `signer`, `issued-at`, `nonce`, `payload-digest`) is absent | | 1 | `bad_header_type` | A protected header has the wrong type or size | | 1 | `unsupported_version` | `atep-version` is not 1 | | 1 | `unsupported_suite` | `suite` is not `ATEP-1` | | 1 | `signature_count_invalid` | The signature array does not have exactly two entries | | 1 | `algorithm_suite_mismatch` | Two entries but not one EdDSA and one ML-DSA-65; or a wrong content or recipient algorithm in tag 96 | | 1 | `unencrypted_non_trust_document` | A bare tag 98 envelope whose content type is not a trust document | | 1 | `missing_expires_at` | An attestation without `expires-at` | | 1 | `atep_r_unencrypted` | ATEP-R channel, envelope not encrypted | | 1 | `missing_command_class` | ATEP-R channel, no `-70014` header | | 1 | `unknown_command_class` | ATEP-R channel, class is not one of the seven | | 2 | `no_recipient_key` | Tag 96 and the verifier has no recipient identity with encryption keys | | 2 | `not_addressed_to_recipient` | Recipient `kid` is not the verifier's Agent ID | | 2 | `kem_failure` | Zero X25519 shared secret or other KEM failure | | 2 | `aead_failure` | AEAD authentication failed | | 3 | `signer_bundle_unavailable` | No inline bundle and signer not cached | | 3 | `signer_bundle_invalid` | Inline bundle not in the exact form of section 4 | | 3 | `signer_id_mismatch` | SHA-256 of the bundle is not `signer` | | 3 | `kid_mismatch` | A signature `kid` is not `signer` | | 4 | `detached_payload_missing` | Detached envelope verified without its payload | | 4 | `eddsa_signature_invalid` | The EdDSA signature does not verify | | 4 | `mldsa_signature_invalid` | The ML-DSA-65 signature does not verify | | 5 | `issued_in_future` | `issued-at` is more than the skew after `now` | | 5 | `expired` | `expires-at` is not later than `now` | | 6 | `nonce_replayed` | Nonce already seen | | 7 | `payload_digest_mismatch` | SHA-256 of the payload is not `payload-digest` | | 8 | `signer_revoked` | Signer revoked (SRL entry or direct entry) or retired (valid `retired` attestation in the local store) as of `issued-at` | | 9 | `policy_invalid` | A rule names a root outside the root set; also the name of the configuration error of a policy that does not parse (section 7) | | 9 | `anchor_not_supported` | The policy contains a `require_anchor` rule and the verifier cannot evaluate it (section 7) | | 9 | `anchor_content_type_invalid` | Published anchor check: the content type is not `application/atep-anchor+cbor` (section 9) | | 9 | `anchor_schema_invalid` | Published anchor check: the payload is not a valid anchor record (section 9) | | 9 | `anchor_log_mismatch` | Published anchor check: the signer is not the log that was asked about (section 9) | | 9 | `anchor_checkpoint_mismatch` | Published anchor check: the `checkpoint-hash` of the record is not the hash of the checkpoint looked at (section 9) | | 9 | `claim_missing` | No candidate attestation for a rule | | 9 | `claim_too_old` | Older than `max_age_days` | | 9 | `claim_data_mismatch` | A required `data` value is absent (peer-motion) | | 9 | `attestation_invalid` | A candidate failed steps 1 to 8; `cause` carries the step and code | | 9 | `attestation_schema_invalid` | Wrong content type, schema, claim rule (including the layouts of `retired` and `successor`), or missing evidence | | 9 | `attestation_issuer_mismatch` | `issuer` is not the signer | | 9 | `attestation_lifetime_exceeded` | More than 400 days | | 9 | `attestation_revoked` | On its issuer's SRL | | 9 | `issuer_not_authorized` | A delegation exists but does not list a needed claim | | 9 | `chain_broken` | A non-root issuer has no delegation in the pool, or the chain does not end at an allowed root | | 9 | `chain_cycle` | An issuer repeats in the chain | | 9 | `chain_depth_exceeded` | More attestations than `max_depth` | | 9 | `srl_unavailable` | No SRL cached for an issuer, fail-closed | | 9 | `srl_stale` | SRL past `next-update`, fail-closed | | 9 | `srl_wrong_content_type`, `srl_schema_invalid`, `srl_issuer_mismatch`, `srl_rollback`, `srl_sequence_conflict` | SRL loading (section 8) | | 9 | `inclusion_proof_missing`, `inclusion_proof_invalid`, `checkpoint_untrusted`, `checkpoint_schema_invalid` | Inclusion proofs and checkpoints (section 9) | | 9 | `consistency_proof_invalid`, `split_view_detected` | Consistency and split view checks (section 9) | The four `anchor_*` codes are results of the published anchor check of section 9 and not of `verify()`: they are raised for a log-signed anchor envelope, after steps 1 to 8 on it, and a failure of steps 1 to 8 is reported with its own step and code. Two names are not verification results and have no step: `anchor_record_invalid`, the result of decoding an anchor record on its own (section 9), and `policy_invalid` when it is the configuration error of a policy that does not parse (section 7). **Result.** A positive result carries `ok` true, `signer` (Agent ID text), `content_type`, `issued_at`, `expires_at` (integer or null), `nonce_hex`, `encrypted`, `claims`, `checkpoint`, `payload_hex`, and, only when present, `warnings` (when non-empty) and `command_class` (when the header `-70014` is present, whether or not the channel is ATEP-R). Each element of `claims` has `claim` (URI), `issuer`, `root`, `expires_at` (the earliest `expires-at` over the chain: the claim is good until then) and `chain`, which starts with the claim attestation and continues with the `issuer-authority` attestations up to the root's delegation (when a rule was satisfied through succession, the `successor` attestation is the link directly after the claim attestation), each link having `id` (hex), `claim`, `subject`, `issuer`, `issued_at`, `expires_at`. The claims are listed in the order the rules of the policy come, then the satisfied alternative's rules in their listed order. `checkpoint` is `null` or `{log, tree_size, root_hash, timestamp}` of the checkpoint with the latest timestamp that was used. A negative result is `{ok: false, step, error, cause?}`. The first eight steps run offline on the envelope alone; step 9 recurses through each attestation and its chain before step 10 releases the payload. ## 11. Security considerations **Compression side channels.** ATEP never compresses before encrypting by default. Combining attacker-influenced data with secrets in one compressed-then-encrypted body leaks secret length (the CRIME/BREACH class). Implementations MAY compress a payload only when the whole payload comes from one trusted party; the envelope records compression in the content type so verifiers can decline such envelopes. **Downgrade.** The suite identifier, version and algorithm IDs live in the protected header and are therefore signed. An attacker cannot strip the post-quantum signature or substitute a weaker suite without invalidating the signature. **Key compromise.** Short attestation lifetimes bound the damage window. Issuers MUST support `compromised` SRL entries and SHOULD keep issuing keys in an HSM. The transparency log makes mis-issuance after a compromise detectable by monitors. The `successor` claim allows orderly rotation: a verifier follows succession only when configured to, for one hop, and monitors alert on a chain of more than one hop (`successor_chain`, section 9). A `successor` attestation signed by a key that is later found to be compromised is bounded by the short lifetime of the attestation and, for a verifier that requires it, by an inclusion proof (section 7). **Replay.** The nonce plus `issued-at` and `expires-at` allow a verifier to reject re-sent envelopes. Stateless verifiers that cannot track nonces should rely on short expiry and application-level request IDs. **Issuer misbehavior.** No issuer is trusted by the protocol; only by a verifier's policy. The log makes every issuance public; monitors detect out-of-scope or unauthorized claims; verifiers can drop an issuer from their roots at any time. A split-view log is detected by checkpoint gossip between logs. **Retirement.** Retirement is an assertion by the identity itself and is only as fresh as the verifier's knowledge of it: a verifier that has not learned of a retirement accepts what the identity signs afterwards. Learning it through an SRL entry carries the freshness semantics of section 8 and learning it through a store does not, which is why section 7 asks for both. Back-dating a retirement can only make an identity less trusted, never more. **Anchoring.** An anchor record is evidence about a checkpoint, never about an identity or an envelope. It adds a second witness against a log that rewrites history, and it adds nothing a verifier needs to verify. A witness that is down, compromised or rewritten changes no verification result of this specification, and a verifier that requires an anchor today fails closed (section 7). **Domain binding.** The domain records show control of a DNS name at one moment to an issuer, and the issuer's check fails closed. DNS answers without DNSSEC can be forged, and the issuer's fetcher is exposed to request forgery; the mitigations are in section 7. **Privacy.** Agent IDs are pseudonymous until bound by attestation. SRLs and the log reveal which attestations exist, not who verified them. Implementations SHOULD avoid putting personal data in claim `data`; put it behind an evidence hash instead. Encrypted envelopes hide the signer from observers. **Harvest-now-decrypt-later.** Hybrid ML-KEM protects encrypted envelopes recorded today against future quantum decryption. Hybrid ML-DSA protects signed attestations from future forgery, which would otherwise retroactively poison the whole trust graph. The claim has limits: it rests on the finalized NIST standards, which are young, and on the hybrid construction, which stays secure if either half holds; the post-quantum libraries of the reference code are young as well, and neither the specification nor the reference code has had an independent security audit. **Offline operation.** Offline verification trades freshness for availability. A verifier that has not refreshed an SRL does not know of a revocation published since: the limits are the `next-update` of the list (a stale list fails closed under the default policy, and always for `motion`, `actuation`, `maintenance` and `safety` commands other than an e-stop under ATEP-R), the short lifetime of attestations (section 7) and, for a retirement, the freshness of the local attestation store, which has none. An identity is self-certifying, because its Agent ID is the hash of its public keys, but an identity proves nothing about certification until its attestations have been checked against issuer keys and lists that were fetched earlier; a robot that must act on motion or actuation commands needs lists fresher than the longest time it can be cut off. **Payload is untrusted.** A verified envelope proves origin and integrity, not that the payload is safe. Instructions inside a payload are data to the receiving agent, never commands. ATEP makes provenance checkable so that a receiving agent can apply different handling to tool output, model assertions and human input; it does not remove the need to do so. ## 12. Conformance and test vectors An implementation is conformant when it passes the published test-vector suite without modification. The suite is the spec's executable form and ships with the reference implementation. This document is normative for behavior: a third implementer needs neither the Rust code nor `vectors/README.md` to know what a verifier, an issuer, a log or a monitor must do, and `vectors/README.md` only documents the file formats of the vectors (where it is silent or says "provisional", this draft decides; where the two ever disagree, a vector wins and the disagreement is a spec bug). **Vector categories.** Each vector is a CBOR file plus its JSON view and an expected result. The suite has 436 vectors in 28 categories, listed in `vectors/manifest.json` with the SHA-256 of each CBOR file. The first 200 are those of Draft 05, unchanged and in the same order (the first 140 are those of Draft 04, the next 60 cover `retired` and `successor`); the 236 for anchoring and discovery are appended in the order of the last nine rows: | Category | Vectors | Covers | | --- | --- | --- | | `identity` | 10 | Fixed key bundles and their expected Agent IDs | | `signing` | 5 | Payload, keys, nonce, timestamps to exact envelope bytes (deterministic ML-DSA) | | `verify-positive` | 8 | Envelopes that MUST verify, with the expected result structure | | `verify-negative` | 22 | One vector per rejection reason of steps 1 to 8 | | `encryption` | 2 | KEM inputs to exact ciphertext, and round trip | | `attestation` | 5 | Attestation issuance to exact bytes | | `chain-positive`, `chain-negative` | 8, 22 | Step 9: a three-level chain to a root, SRL freshness, inclusion proofs, and the failing variants (broken chain, unauthorized issuer, cycle, depth, revoked, schema, and more) | | `srl` | 8 | SRL loading: valid, stale, bad signature, issuer mismatch, schema, rollback, conflict | | `log` | 22 | Checkpoints, inclusion proofs, consistency proofs, checkpoint schema and split views | | `atep-r-positive`, `atep-r-negative` | 12, 16 | ATEP-R command classes, e-stop, fail-closed behavior, step 1 rules | | `retired-positive`, `retired-negative` | 15, 13 | Step 8 with a valid retirement in the local store: boundary, expiry, ignored store entries, SRL and direct entries, the union with `compromised`, the exemption, delegations, ATEP-R (cases RT1 to RT23, RT26 to RT29) | | `successor-positive`, `successor-negative` | 9, 16 | Step 9 with `follow_succession`: one hop, own claims first, revocation and expiry of the successor attestation, SRL behavior, depth, ATEP-R (cases SU1 to SU24) | | `srl-context` | 2 | Loading an SRL in the verifier's own context (RT24, RT25) | | `log-admission` | 4 | Log admission of `retired` and `successor` (RT30, RT31a, RT31b, SU26) | | `monitor` | 1 | The `successor_chain` alert (SU25) | | `checkpoint-hash` | 9 | The checkpoint hash, `SHA-256` of the verified payload bytes: five accepted checkpoints (two envelopes of one payload, the empty tree, an eight byte tree size) and four rejections that yield no hash | | `anchor-record` | 36 | Anchor record decode and encode: nine valid records and 27 rejections (unknown, missing and mistyped keys, a bad `chain-id`, size limits, and encodings that are not strict deterministic CBOR) | | `chain-id` | 30 | The `chain-id` accept and reject table: the five registered ids, the `x-` extension rule and what it refuses | | `anchor-envelope` | 13 | The published anchor check: three valid anchors, forged signatures, another signer, another checkpoint, a wrong content type and bad payloads | | `anchor-media-type` | 3 | `application/atep-anchor+cbor` as a signed-only trust document at step 1, and a look-alike type that is not one | | `require-anchor` | 27 | The policy member `require_anchor`: valid rules and every invalid shape (`policy_invalid`, no step) | | `anchor-not-supported` | 9 | The step 9 result `anchor_not_supported` and its place after steps 1 to 8 | | `registry-endpoint` | 26 | Log admission of `registry-endpoint` attestations: the four kinds, the extension kind, the URL rules, the agent card digest and the closed vocabulary | | `domain-binding` | 83 | Domain records and the binding check as fixtures: the well-known document, the TXT record, either source enough, disagreement, stale records, limits, redirects and names | *Which vectors apply to whom.* 195 of the first 200 vectors apply to a verifier: every category except `log-admission` and `monitor`. Of the 236 added after Draft 05, 69 apply to every verifier because a verifier has to parse its own policy and read step 1: `anchor-media-type` (3), `require-anchor` (27), `anchor-not-supported` (9) and `chain-id` (30), which the policy parser needs. 58 apply to an implementation that reads checkpoints, anchors and log-signed documents (`checkpoint-hash` 9, `anchor-record` 36, `anchor-envelope` 13), and 109 to a log or an issuer (`registry-endpoint` 26, `domain-binding` 83); a verifier that does none of these may report them as skipped by name, as it does `log-admission` and `monitor` (decision 58). A verifier that runs only the required set runs 264 of the 436. Status: the Rust implementation passes all 436; the npm package and the Python implementation each pass 431 and report 5 as skipped by name, the 4 `log-admission` vectors and the 1 `monitor` vector, because they have no stateful log and no monitor; both run the 26 `registry-endpoint` vectors through a function of the submission alone (a pure check of the attestation, no log state) and the 83 `domain-binding` vectors through a function of the fixture (decision 81). **Requirements for a conformant verifier:** implement every step of section 10; reject on first failure; never accept a single-signature envelope under `ATEP-1`; expose the failing step in its result; reject any CBOR that is not deterministic per RFC 8949 section 4.2.1 at step 1, on receipt as well as when signing; reproduce the warning strings of section 8 verbatim. A conformant issuer reproduces the `signing`, `attestation` and `encryption` vectors byte for byte from their inputs. A conformant verifier passes the 264 vectors that apply to every verifier (195 of the first 200 and the 69 above), and those of the other categories for each function it provides. A conformant log passes the `log` vectors and, for its admission rules, `log-admission` and `registry-endpoint`; a conformant monitor passes the `log` vectors and `monitor`, and `checkpoint-hash`, `anchor-record` and `anchor-envelope` when it shows anchors; an issuer of `domain-control` passes `domain-binding`. The rest of their service behavior (the API, the other alerts) is specified in section 9 and exercised by the reference implementation's tests but has no vector yet (table below). **Draft 07 conformance.** The vectors cover `retired`, `successor`, the checkpoint hash, anchor records, the `chain-id` table, the policy member `require_anchor`, `registry-endpoint` and the domain records, but not everything Draft 04 to Draft 06 add (the known gaps below), so an implementation that passes all the vectors that apply to it can still be wrong there. An implementation that claims Draft 07 MUST apply a valid `retired` attestation at step 8 (the `retired-*` vectors), MUST reject a policy that contains a `require_anchor` rule with `anchor_not_supported` at step 9 unless it evaluates the rule, MUST accept the anchor media type as a trust document, and MUST reject a policy member it does not know as the configuration error `policy_invalid` (section 7), so an implementation without succession rejects a policy that sets `follow_succession`: it passes every vector that does not set the member and cannot pass the `successor-positive` vectors, and it says so. Following succession and evaluating `require_anchor` are otherwise optional. **Vector files.** `.cbor` is the object under test (for `verify-*`, `chain-*` and `atep-r-*` the envelope handed to the verifier, tag 98 or tag 96, with the chain vectors always an encrypted data envelope to the recipient carrying its attestations inline under `-70009`); `.json` is the informational JSON view; `.expected.json` holds `inputs` and `expected`. The reference time of every verification vector is `now = 1800000000`. The verifier context of section 7 is supplied under `inputs.policy`: `now`, `max_skew_secs`, `recipient_seeds`, `known_bundles`, `seen_nonces`, `revocations`, `detached_payload_hex`, `trust` (the trust policy object of section 7, exactly that format), `attestations` (the local attestation store) and `srls` (SRLs the verifier loads into its cache before verifying). `expected` is the result of section 10 (a positive result, or `{ok: false, step, error, cause?}`). The SRL vectors take `now`, `srl_policy.on_stale`, `known_bundles` and `cached_srl_hex` and expect an SRL load result (`ok`, `issuer`, `sequence`, `issued_at`, `next_update`, `stale`, `revoked` entries as `{kind, id_hex or id, reason, revoked_at}`, and `warnings`) or a rejection; a stale list under fail-closed is `srl_stale` at step 9. The `log` vectors take `now`, `check` (`checkpoint`, `inclusion`, `consistency` or `split-view`) and `trusted_logs`, with the documents of section 9 as the CBOR file. The exact JSON of these files is in `vectors/README.md`. **Formats of the categories added in Draft 04 and 05.** The `retired-*` and `successor-*` vectors are verification vectors in the format above. `inputs.policy.attestations` is the local attestation store, which step 8 reads for retirements (also when there is no `trust` policy) and which is the third pool source of step 9; `trust` may set `follow_succession`; `revocations` are the directly supplied revocations of step 8. The CBOR file is the encrypted data envelope `E(t)` of the case tables, with its attestations inline under `-70009`, except RT18, RT19 and RT20, where it is the unencrypted `retired` attestation under test and `recipient_seeds` is absent. The `srls` are loaded in list order, each in the context of the ones before it (section 8); a list that fails to load makes a vector invalid, and no vector supplies one. *`srl-context`* (RT24, RT25) has the inputs of the `srl` vectors (`now`, `srl_policy.on_stale`, `known_bundles`, `cached_srl_hex`) and, at the top level of `inputs` and not under `inputs.policy`, the optional `attestations` (the local attestation store, a list of hex envelopes) and `revocations` (as in `inputs.policy`). The CBOR file is the SRL to load. `cached_srl_hex`, when it is not null, is loaded first in that context, without any staleness test (loading never tests staleness, section 8), and then the CBOR file is loaded in the same context; `srl_policy.on_stale` applies to that last load only. The result is that of an `srl` vector. For RT25 the cached list and the file are the same bytes, which the reload rule of section 8 accepts. *`log-admission`* (RT30, RT31, SU26): the CBOR file is the submission. `inputs`: `now`; `log`, the log's Agent ID; `max_envelope_bytes`; and `logged`, the submitted forms (hex) of the documents the log already holds, in order, each of them admitted first at `now` under the same rules. The rules are those of section 9 ("Admission") with the 14 core claim types as the vocabulary; the identity entries of the SRLs the log holds are the revocations of step 8 and the `retired` attestations it holds are its local attestation store. `expected` is `{ok: true, document: "attestation"}` or `{ok: false, refusal}` with the stable refusal name of section 9, and with `step` and `error` added for `verification_failed` (the failing step 1 to 8 of the submitted form and its code). *`monitor`* (SU25): the CBOR file is the map `{"entries": [envelope, ...]}`, the entries of a log in log order (submitted forms, embedded as CBOR values), numbered from 0; a real log holds its policy entry first, so its own indices are one higher. `inputs`: `now` and `check` (`alerts`). `expected` is `{alerts: [{alert, entry, issuer, subject}]}` in the order of the entries. Each entry is verified as of its own `issued-at` with its inline bundle, as the monitor does. *Formats of the anchoring and discovery categories (Draft 06).* Objects under test that are JSON (`require-anchor`, `domain-binding`, and the text of a `chain-id`) have a `.cbor` file that is the deterministic CBOR encoding of the value `inputs` carries (`inputs.policy`, `inputs.fixture`, `inputs.id`), so that the manifest hash pins it; an implementation reads the value from `.expected.json` and never needs to decode that `.cbor`. Only integers appear, no floats. The identities are the usual ones (`log` signs checkpoints and anchors, `mallory` is another signer, `alice` issues the `registry-endpoint` attestations and signs the data envelopes of `anchor-not-supported`, `root` is the root issuer, `bob` and `carol` hold encryption keys) and `now` is 1800000000. * `anchor-media-type` (3) and `anchor-not-supported` (9) are verification vectors in the format above (`inputs.policy`, `expected` the section 10 result, compared whole). The `.cbor` of the first is a bare tag 98 envelope, that of the second an encrypted data envelope from `alice` to `bob`. A bare anchor envelope with no `expires-at` is accepted at step 1, even when its payload is a text string (the core verifier checks the label only); `application/atep-anchor+json` is `1/unencrypted_non_trust_document`. * `require-anchor` (27): `inputs` `{check: "require-anchor", policy}`; `expected` `{ok: true, require_anchor: [{log, chain, max_age_days | max_age_hours}, ...]}` in array order, `log` in the `atep:` form, or `{ok: false, error: "policy_invalid"}` with no step. * `chain-id` (30): `inputs` `{check: "chain-id", id}`, one vector per text; `expected` `{ok: true, kind: "registered"}`, `{ok: true, kind: "extension"}` or `{ok: false}`. * `checkpoint-hash` (9): the `.cbor` is a checkpoint envelope; `inputs` `{now, trusted_logs}`; it is checked as a `log` vector with `check: checkpoint` (a failed envelope is `9/inclusion_proof_invalid` with `cause`, then `9/checkpoint_untrusted` and `9/checkpoint_schema_invalid`); on success `expected` is the checkpoint result plus `checkpoint_hash` (lowercase hex of the `SHA-256` of the payload bytes of the verified envelope) and `payload_hex`. * `anchor-record` (36): the `.cbor` is the bytes of a record payload, or any bytes; five rejection vectors are not strict deterministic CBOR on purpose (unsorted keys, a duplicate key, an indefinite length map, a non shortest integer head, trailing bytes) and have a `.json` of `{"not_strict_cbor": true, "hex": ""}`; `inputs` `{check: "anchor-record"}`; `expected` `{ok: true, record: {checkpoint_hash, chain_id, transaction_id, block_height, anchored_at}}` (`checkpoint_hash` hex, `block_height` null when the key is absent) or `{ok: false, error: "anchor_record_invalid"}`. For an accepted vector, encoding the record from `expected.record` gives the `.cbor` bytes. * `anchor-envelope` (13): the `.cbor` is a log-signed anchor envelope; `inputs` `{now, log, checkpoint_hash_hex}` (the log asked about, as Agent ID text, and the hash of the checkpoint looked at); `expected` `{ok: true, log, record}` or `{ok: false, step, error}`, the result of "Checking a published anchor" (section 9). * `registry-endpoint` (26): the format of `log-admission`: the `.cbor` is the submitted attestation (claim `https://atep.dev/claims/registry-endpoint`, issued by `alice` for herself), `inputs` `{now, log, max_envelope_bytes, logged: []}`; `expected` `{ok: true, document: "attestation"}` or `{ok: false, refusal: "schema_invalid"}`, and `claim_vocabulary` for a look-alike claim URI (`registry-endpoints`). * `domain-binding` (83): `inputs` `{check: "domain-binding", fixture}` with ``` fixture = { "domain": "", "agent_id": "atep:...", "options": {"require_both": bool, "require_dnssec": false}, "well_known": { "": , ... }, the fetcher's answers by host name "txt": { "": , ... } the fetcher's answers by TXT name } well-known answer = {"unavailable": ""} | {"status": int, "final_url": text, "content_type": text or null, "body": text} | the same with "body_filler": {"prefix", "fill", "suffix", "total_bytes"} instead of "body" (the prefix, then the one character fill repeated so that the whole is exactly total_bytes octets, then the suffix) txt answer = {"unavailable": ""} | {"records": [ text | [text, ...] ], "dnssec_validated": bool} (a record given as a list is its character-strings, concatenated with nothing between) expected = {"well_known": state, "dns": state, "outcome": "bound" | "not-bound" | "indeterminate", "queried": {"well_known": [hosts asked, in order], "txt": [names asked, in order]}} state = "listed" | "not-listed" | "absent" | "invalid" | "unavailable" | "not-read" ``` A host or TXT name with no key in the fixture is one for which the fetcher says nothing exists, which is silent (*absent*). The checker asks for the well-known document of `domain` and the TXT records of `_atep.` and nothing else, and `queried` is normative: it shows that nothing else was read. No network is involved. The sources are judged by sections 4 and 7: the states of the well-known document by "How the checker applies these rules", of the TXT source by "DNS record" and "Which record counts", and the outcome by "Checking a domain binding", step 3. `dnssec_validated` changes no state while `require_dnssec` is false, and no vector sets the option. **Deterministic generation inputs.** The generation vectors are a pure function of fixed seeds, so an independent implementation needs the following, which are normative for reproducing them and carry no meaning for production use. A key bundle is derived from seeds: `ed25519` is the 32 byte RFC 8032 secret key; `mldsa65` is the 32 byte FIPS 204 seed xi, used with `ML-DSA.KeyGen_internal`; `x25519` is the 32 byte RFC 7748 scalar (clamped on use); `mlkem768` is the 64 byte FIPS 203 seed `d || z`, used with `ML-KEM.KeyGen_internal`. Signing uses the deterministic ML-DSA variant (rnd = 32 zero bytes). Encryption uses a given ephemeral X25519 scalar, a given 32 byte ML-KEM randomness `m` with `ML-KEM-768.Encaps_internal(ek, m)` (FIPS 203 Algorithm 17), and a given 12 byte IV. The nonces, attestation ids and seed values of every vector are written in plain hex in its `inputs`, so no derivation has to be reproduced. The `encryption` vectors publish the intermediate values (ephemeral public key, both shared secrets, HKDF info, AES key) for locating a divergence. **Interoperability rule.** Two independent implementations (the Rust core and the Python implementation written against the spec rather than against the Rust code) MUST produce byte-identical envelopes from the same vectors before the Internet-Draft is submitted. Any divergence is a spec bug, not an implementation bug. Status: the Rust core passes all 436 vectors; the npm package (the Rust core compiled to WebAssembly) and the Python implementation each pass 431 and report 5 as skipped by name (the 4 `log-admission` and the 1 `monitor` vector), and the Rust log and monitor pass those five. The Python implementation, written from Draft 02 and the vectors only, reproduced every generation vector without a guess, and Draft 03 is the result of its 20 reports; it was then extended to `retired` and `successor` from Draft 04 and the vectors only, and Draft 05 is the result of its 11 further reports (21 to 31 of `docs/implementation-findings/python-findings.md`) and of the 8 reports of the Rust implementation (42 to 49 of `docs/implementation-findings/rust-findings.md`); it was finally extended to the 236 anchoring and discovery vectors from Draft 05, the vector notes and the vectors only, and Draft 06 is the result of its 13 reports (32 to 44) and of the 7 reports of the Rust implementation (50 to 56). That is the measure of completeness: each report is a place where the text, the notes and the README together did not determine a behavior. Draft 06 changes no vector. **Known vector gaps.** Behavior that this specification states but no vector tests yet. Each row is normative text; an implementation that gets it wrong still passes the vectors, so these are where a third implementation is most likely to diverge unnoticed. Closing them is open work (section 13). Closed by vectors in Draft 06, and removed: rows 16 to 19 of Draft 05 (the checkpoint hash, the anchor record and the published anchor envelope, the `chain-id` table, `require_anchor` parsing and `anchor_not_supported`) and row 21 in its first form (`registry-endpoint` data); rows 20, 21 and 23 keep only what the vectors of `domain-binding`, `registry-endpoint` and `require-anchor` do not decide. The numbers of the other rows are kept because other documents cite them, and no number is reused; rows 25 to 27 are new. | # | Behavior | Section | Notes | | --- | --- | --- | --- | | 1 | `retired`: the parts of the rule no vector decides: a store entry that is encrypted, signed by another identity or not an envelope; several valid retirements of one identity (the earliest decides); a malformed `retired` attestation of X issued after R (exempt at step 8, a schema failure for the consumer); the reload of cached SRL bytes followed by a newer list of the same issuer | 7, 8, 10 | Specified in Draft 05; cases RT32 to RT35 below have no vector. The rest of the rule (layout, valid retirement, step 8 through the store, an SRL entry or a direct entry, the exemption, the SRL of a retired issuer, ATEP-R) is covered by RT1 to RT31 and implemented in Rust, npm and Python | | 2 | `successor`: a claim issued by a root and inherited with `max_depth` 1 (the successor attestation is not refused for depth); the order of warnings under succession; a fork (two successors of one identity) | 7, 8, 14 | Specified in Draft 05; case SU27 below has no vector. The rest (layout, `follow_succession`, one hop, revocation, depth, the result chain) is covered by SU1 to SU26 and implemented in Rust, npm and Python | | 3 | Step 1 for a malformed outer COSE\_Encrypt (wrong sizes of ephemeral key or KEM ciphertext, recipient arity) | 10 | The Python implementation reports it at step 2 today | | 4 | Ed25519 strict verification edge cases (non-canonical S, small-order A or R, non-canonical R) | 5 | No vector exercises them | | 5 | Hedged ML-DSA signing inputs | 5 | Both modes verify identically; vectors use only deterministic | | 6 | Order of several candidate attestations and the first-candidate error rule; pool order, deduplication and caps | 7, 10 | Vectors have one candidate per hop | | 7 | Alternatives with ties, and counting of `claim_data_mismatch` in "most rules satisfied" | 10 | | | 8 | ATEP-R: missing SRL fails closed for motion, actuation, maintenance and non e-stop safety; combinations of step 1 failures; e-stop expiry relaxation limits | 17 | The fail-closed vectors carry fresh SRLs or stale ones, never a missing one | | 9 | Chain depth boundary for delegations (exactly `max_depth` attestations) | 7 | Only one exceeded and one within-bound case | | 10 | SRLs that fail to load when supplied with a verification | 8, 20 | Reported as a configuration error in the Rust code | | 11 | Consistency and split view checks whose checkpoint has a bad signature | 9 | The `inclusion_proof_invalid` mapping is tested for `checkpoint` only | | 12 | Log admission other than the `retired`, `successor` and `registry-endpoint` cases, log API, log policy entry, directories, gossip over HTTP | 9 | Exercised by the reference tests only | | 13 | Monitor alerts other than `successor_chain`, including the chain depth rule | 9, 20 | Exercised by the reference acceptance tests only | | 14 | Log key rotation by `successor` | 9 | Not implemented | | 15 | Replay (step 6) across attestations, `seen_nonces` window rules | 10 | Vector only checks one envelope | | 20 | Domain records beyond the 83 vectors: a document of more than 1,024 `agents` entries (the first 1,024 are read, the 1,025th place is ignored); a listing in a 17th TXT record; the `require_dnssec` option; the public suffix refusal; the limit of three redirects and the refusal of non-public addresses; a duplicate member name in the JSON | 4, 7 | Nothing in them is signed, so the vectors are fixtures. The 1,025th entry has no vector, so what is said in section 4 rests on the two implementations agreeing (both read 1,024 entries; the Rust one adds a warning). A 17th record: the Rust implementation ignores it, the Python implementation reads it, so the answer differs. `require_dnssec`: *invalid* in Rust, *unavailable* in Python. The public suffix check is not implemented anywhere. Redirect count and addresses are duties of the fetcher, which a fixture does not show. A duplicate member name: the last value wins in both implementations | | 21 | `registry-endpoint` edges: a non ASCII white space, control or format character in the `url`; a bracketed host that is not closed (`https://[`); `HTTPS://`; a `url` of non ASCII characters at the 2,048 limit; a `kind` with a hyphen at either end (`x--`, `x-a-`) | 7, 9 | The 26 vectors avoid them. Rust and npm accept the first two, Python refuses them; all three refuse an uppercase scheme, count characters and accept the `kind` hyphens, as the CDDL does | | 22 | Claim-type resolution paths, content negotiation, `claim_unknown`, OpenAPI document | 9 | Service behavior; Rust tests only, including validation of live responses against the OpenAPI component schemas | | 23 | The configuration error `policy_invalid` for members of the policy other than `require_anchor` (unknown member, wrong type, malformed rule); an age that is not an integer or is above 2^63-1; where the error is raised, before step 1 or inside step 9 | 7 | A configuration error is not a verification result, so the vector format gives it the result `{ok: false, error: "policy_invalid"}` with no step, and only `require_anchor` has vectors (27). Numbers that CBOR cannot carry cannot be vectors. Rust and npm parse the policy before any envelope; Python parses it inside step 9, after steps 1 to 8 | | 24 | `chain_cycle` and depth accounting with the successor attestation as one link: the empty path, neither O nor S seeded | 7 | SU19 and SU20 test the depth; no vector tests a cycle through succession | | 25 | A published anchor under a tag 96 envelope; `expires-at` on an anchor; a `log` argument that is not an Agent ID; an integer of 2^63 or more in a record | 5, 9 | Decided in section 9, no vector. An encrypted anchor is `2/no_recipient_key` in Rust and npm and `1/unexpected_encryption` in Python; an expired anchor fails at step 5; a bad `log` argument is `anchor_log_mismatch` in Python and a caller error in Rust and npm | | 26 | Evaluation of anchors against a witness: fetching an anchor, checking that the witness holds the hash, the staleness measure, evaluation of `require_anchor`, anchor alerts in the monitor | 7, 9, 13 | Milestone M5. No log anchors anything, no verifier evaluates a rule; the `anchor-not-supported` vectors pin the refusal that stands in for evaluation | | 27 | Log admission: the refusal for a submission that is malformed at step 1 and also has a content type that is not loggable | 9 | Rust reads the content type after the structural decode and refuses `verification_failed`; Python, which has no log, reads it after steps 1 to 8 reject and refuses `data_envelope` or `content_type_not_loggable`; no vector separates them | **Cases for `retired` and `successor`.** These are the cases a vector set for the two claims has to cover, written so that an implementer can build the vectors from this text alone; the vectors of the suite (`vectors/retired-*`, `successor-*`, `srl-context`, `log-admission` and `monitor`, one per case, named by the case number) were built from these tables and the tables follow the fixtures they use. Every case uses `now = 1800000000`, the reference time of every vector. `X` is an identity that retires; `O` is an old identity and `S` its successor (`O0` the identity before O in SU8); `I` and `P` are root issuers (`i` and `p` in the vectors) and `Y` any other identity. `R` is the `retired` attestation of X with `issued-at` 1799990000 (called `T_r`), `expires-at` 1830000000, `data` `{}`, held in the verifier's local attestation store unless a case says otherwise. `E(t)` is a data envelope signed by X with `issued-at` t and no `expires-at`, encrypted to the recipient as in the `chain-*` vectors, verified with no trust policy unless a case names one. `accept` is a positive result. A rejection is written `step/code`; `9/attestation_invalid (8)` is step 9, code `attestation_invalid`, with `cause {step 8, signer_revoked}`. An "SRL entry" is a 32 byte identity entry in an SRL of any issuer in the cache. *Fixture of the SRLs* (decision 45), unless a case says otherwise. A fresh SRL that names one unrelated attestation and nothing else (so that no case produces a warning by accident) is cached for every issuer whose attestations step 9 evaluates; a case with no trust policy needs none. An issuer that is retired or revoked from an instant cannot publish a list issued at or after it (RT24, decision 36), so the list of such an issuer is issued before that instant and is not yet past `next-update` at `now`: the list of X is issued at 1799980000, that of O at 1799400000 and that of O0 (SU8) at 1799300000, each with `next-update` 1800082800. The lists of every other issuer are issued at 1799996400 as in the chain vectors. A 32 byte entry that names X or O is in the list of another issuer (`I`), because a list that names its own issuer from an instant at or before its own `issued-at` fails step 8 when it is loaded into a context that holds it (section 7; RT25 is the case with a later instant); a 16 byte entry that names an attestation `id` is in the list of that attestation's issuer, as section 8 requires (RT10, SU15). *Retirement (`retired`).* | Case | Inputs | Expected | | --- | --- | --- | | RT1 | store: R; envelope E(1799989999) | accept | | RT2 | store: R; E(1799990000) | `8/signer_revoked` (the boundary is inclusive) | | RT3 | store: R; E(1799999999) | `8/signer_revoked` | | RT4 | store: R with `expires-at` 1799995000 (already expired); E(1799999000) | `8/signer_revoked` (the expiry of a retirement is not compared) | | RT5 | store empty; R only inside the `-70009` array of E(1799999000) | accept (inline attestations are not read at step 8) | | RT6 | store: an attestation with claim `retired`, `issuer` X and `subject` Y; E(1799999000) | accept (not a valid retirement); the same attestation as a step 9 candidate is `9/attestation_schema_invalid` | | RT7 | store: R with one signature byte changed; E(1799999000) | accept (R fails step 4 and is ignored) | | RT8 | store: R with `data` `{"reason": 5}`; E(1799999000) | accept (schema failure, ignored) | | RT9 | store: R with `expires-at` 1834636400 (401 days); E(1799999000) | accept (lifetime over 400 days, ignored) | | RT10 | store: R; an SRL of X has a 16 byte entry naming the `id` of R; E(1799999000) | `8/signer_revoked` (an SRL cannot lift a retirement) | | RT11 | store empty; SRL entry with reason `retired` and `revoked-at` 1799990000; E(1799990000) | `8/signer_revoked` | | RT12 | as RT11; E(1799989999) | accept | | RT13 | store empty; directly supplied revocation `{X, retired, 1799990000}`; E(1799990000) | `8/signer_revoked` | | RT14 | store: R; SRL entry with reason `compromised` and `revoked-at` 1799980000; E(1799985000) | `8/signer_revoked` (the earlier instant wins) | | RT15 | as RT14; E(1799979999) | accept | | RT16 | store: R; SRL entry `compromised` with `revoked-at` 1799995000; E(1799992000) | `8/signer_revoked` (the retirement applies although the entry is later) | | RT17 | as RT16; E(1799989999) | accept | | RT18 | store: R; verify R itself as an envelope, no policy | accept (exempt from the retirement rule) | | RT19 | store: R; verify R2, a `retired` attestation of X with `issued-at` 1799995000 | accept (exempt) | | RT20 | store: R; SRL entry `compromised` with `revoked-at` 1799980000; verify R itself | `8/signer_revoked` (the exemption does not cover an SRL entry) | | RT21 | store: R; policy `{roots: [X], rules: [{claim: operator}]}`; signer Y holds an `operator` attestation from X with `issued-at` 1799980000; E signed by Y | accept, the claim is reported | | RT22 | as RT21 with the attestation `issued-at` 1799990000 | `9/attestation_invalid (8)` | | RT23 | store: R; policy `{roots: [P], rules: [{claim: operator}]}`; P delegated `operator` to X (`issuer-authority`); Y holds an `operator` attestation from X with `issued-at` 1799985000 | accept, chain: claim attestation, delegation | | RT24 | `srl-context`; store: R; load an SRL signed by X with `issued-at` 1799995000 | rejected at load, `8/signer_revoked` | | RT25 | `srl-context`; load an SRL signed by X with `issued-at` 1799985000 that holds the entry `{X, retired, 1799990000}`, then load the same bytes again | accept both times (the entry is later than the `issued-at` of the list; the second load is the reload rule of section 8) | | RT26 | ATEP-R; signer X holds `fleet-member` and `safety-certified` from a trusted root; class `safety`, payload `{"command": "e-stop"}`; store: R; E(1799999000) | `8/signer_revoked` (the e-stop relaxation covers claim expiry only) | | RT27 | as RT26 with E(1799989999) | accept, claims reported | | RT28 | ATEP-R `motion` from Y; Y's `fleet-controller` attestation was issued by X before `T_r` under a delegation from root P; the SRL of X has `next-update` 1799999000 and the SRL of P is fresh; store: R | `9/srl_stale` (fail-closed class) | | RT29 | ATEP-R e-stop from Y; Y's `fleet-member` and `safety-certified` attestations were issued by X before `T_r` under a delegation from root P; same SRLs; store: R | accept with the warning `SRL of is past next-update 1799999000; using the stale copy` | | RT30 | `log-admission`; the log holds R; submit an attestation signed by X with `issued-at` 1799999000 | refused `verification_failed`, step 8, `signer_revoked` | | RT31 | `log-admission`. (a) The log holds R; submit R2, a `retired` attestation of X with `issued-at` 1799995000. (b) The log holds nothing; submit a `retired` attestation with `issuer` X and `subject` Y | (a) R2 admitted (the exemption); (b) `schema_invalid`. Submitted to a log that holds R, and issued at or after R, the document of (b) is refused `verification_failed` at step 8 first, because only a `retired` attestation of X for X is exempt (decision 47) | *Succession (`successor`).* Fixture, unless a case changes it: policy `{roots: [I], rules: [{claim: operator, root: I}], follow_succession: true}`; the signer is S, and E is a data envelope signed by S with `issued-at` 1799999000. `A_O` is an `operator` attestation with issuer I and subject O, `issued-at` 1799000000, `expires-at` 1830000000. `SUC` is a `successor` attestation with issuer O and subject S, `data` `{}`, `issued-at` 1799500000, `expires-at` 1830000000. Both are inline in E. Fresh SRLs of I (issued 1799996400) and O (issued 1799400000) are cached and name nothing. | Case | Inputs | Expected | | --- | --- | --- | | SU1 | `A_O`, `SUC` | accept; `claims[0].chain` is `A_O` then `SUC`, `expires_at` 1830000000 | | SU2 | as SU1 with `follow_succession` absent | `9/claim_missing` | | SU3 | `SUC` only | `9/claim_missing` | | SU4 | `A_O` only | `9/claim_missing` | | SU5 | `A_O`, `SUC` and `A_S`, an `operator` attestation from I with subject S | accept through `A_S`; the chain is `A_S` alone | | SU6 | as SU5 and the SRL of I names `A_S` | `9/attestation_revoked` (a negative result about S itself is final) | | SU7 | as SU5 with `A_S` issued 1790000000 and `max_age_days` 10 in the rule | `9/claim_too_old` | | SU8 | `A_O0` (subject O0), `SUC1` (issuer O0, subject O), `SUC` (issuer O, subject S) | `9/claim_missing` (a second hop is not followed) | | SU9 | `A_O`, `SUC`; store: a retirement of O with `issued-at` 1799500000 | `9/claim_missing` (`SUC` fails step 8, boundary inclusive) | | SU10 | as SU9 with the retirement `issued-at` 1799500001 | accept | | SU11 | `A_O`, `SUC`; SRL entry `compromised` for O with `revoked-at` 1799500000 | `9/claim_missing` | | SU12 | as SU11 with `revoked-at` 1799500001 | accept (the successor predates the compromise) | | SU13 | `A_O`, `SUC` with `expires-at` 1799999999 | `9/claim_missing` | | SU14 | `A_O`, and a `successor` attestation with issuer S and subject S, or with `data` `{"reason": 5}` | `9/claim_missing` (schema failure) | | SU15 | `A_O`, `SUC`; the SRL of O names the `id` of `SUC` | `9/claim_missing` | | SU16 | `A_O`, `SUC`; the SRL of O has `next-update` 1799999000; `srl.on_stale` fail-closed | `9/claim_missing` (`SUC` fails with `srl_stale`) | | SU17 | as SU16 with `srl.on_stale` fail-open | accept, warning `SRL of is past next-update 1799999000; using the stale copy` | | SU18 | `A_O`, `SUC`; `max_age_days` 10 in the rule | `9/claim_missing` (`A_O` is too old; the error is the one without succession) | | SU19 | roots `[P]`; `A_O` issued by I; `D1`, an `issuer-authority` attestation from P to I listing `operator`; `max_depth` 3 | accept; chain `A_O`, `SUC`, `D1` | | SU20 | as SU19 with `max_depth` 2 | `9/claim_missing` (the chain through succession is one deeper than the direct chain, which would pass at depth 2) | | SU21 | policy `{roots: [I], rules: [], atep_r: true, follow_succession: true}` (no fixture rule: a `fleet-member` attestation cannot satisfy the rule `operator`, so the class requirement is the only thing evaluated, decision 46); E is class `telemetry`; `A_O` is a `fleet-member` attestation of O from I; `SUC` | accept, `command_class` `telemetry`, one claim `fleet-member`, chain `A_O`, `SUC` | | SU22 | as SU21 with `follow_succession` absent | `9/claim_missing` | | SU23 | `SUC` in the local store, `A_O` inline | accept (both are pool sources) | | SU24 | `A_O`, `SUC`; store: a retirement of O with `issued-at` 1799990000, after `SUC` | accept (O's retirement does not retire S) | | SU25 | `monitor`; the log holds `SUC1` (issuer O0, subject O) and `SUC` (issuer O, subject S), in that order | alert `successor_chain` for the entry of `SUC` only (its issuer O is the subject of `SUC1`); the same alert for the same two links logged in the reverse order | | SU26 | `log-admission`; a `successor` attestation with `subject` equal to `issuer`, submitted to a log that holds nothing | refused `schema_invalid` | *Cases with no vector yet.* These close the known gaps 1 and 2 when they get one. | Case | Inputs | Expected | | --- | --- | --- | | RT32 | `srl-context`; load a list of X (sequence 1, `issued-at` 1799985000) that names X from 1799980000, which loads; load the same bytes again; then load a list of X (sequence 2, `issued-at` 1799986000) that names X from the same instant | the first two loads are accepted (the second is no change); the third is `8/signer_revoked` | | RT33 | store: R; verify R3, a `retired` attestation of X with `issued-at` 1799995000 and `data` `{"reason": 5}`; the same document as a step 9 candidate and submitted to a log that holds R | accept at step 8 (exempt); `9/attestation_schema_invalid` as a candidate; `schema_invalid` at the log | | RT34 | store: R and R', a valid retirement of X with `issued-at` 1799980000; E(1799985000), then E(1799979999) | `8/signer_revoked`, then accept (the smaller `issued-at` decides) | | RT35 | store: bytes that are not an envelope, an encrypted retirement of X, a retirement of X signed by Y, then R; E(1799999000) | `8/signer_revoked` (the other entries are ignored and do not stop the scan) | | SU27 | roots `[I]`, `max_depth` 1, otherwise the fixture | accept; chain `A_O`, `SUC` (decision 52) | ## 13. Implementation roadmap Five milestones, each ending in something a third party can run. M1 to M3 are done, M4 is done in its core, and M5 (optional anchoring) has its hooks in place and its adapters still to do; the table records what was delivered and the evidence, and what remains. Dates are set once work starts. | Milestone | Deliverable | Status and evidence | | --- | --- | --- | | M1: Core | Rust crate `atep-core` + `atep` CLI: keygen, sign, verify, encrypt, decrypt, Agent ID derivation; PQ via the RustCrypto `ml-dsa` / `ml-kem` crates (young libraries, not independently audited at the versions used) | **Done.** The CLI round-trips on all identity, signing and encryption vectors and rejects every verify-negative vector at the named step. One deviation from the plan: the COSE structures and a strict deterministic CBOR codec are written in the crate rather than taken from `coset`, so that receivers reject non-deterministic input exactly (section 5). Resolved the 17 issues of Draft 02 | | M2: Trust | Attestations, claim-type core set, trust policy engine, SRL fetch and cache, chain walking, inclusion proofs, ATEP-R | **Done.** The attestation, chain, SRL, log (checkpoints and inclusion) and ATEP-R vectors pass. Resolved the issues 18 to 31 in Draft 03 | | M3: Registry | A service: Merkle log, checkpoints, inclusion and consistency proofs, issuer and claim directories, gossip, monitor | **Done, in Rust rather than Go.** The reference log is Rust (`atep-log`, the `atep-logd` server, plain HTTP, file storage with crash recovery and full re-verification at start) and the reference monitor is Rust (`atep-monitor`); both reuse `atep-core` for every verification, so no second verifier could diverge. The acceptance test (`atep-monitor/tests/acceptance.rs`) injects a mis-issuance (an issuer outside its delegated claim types and two unauthorized `domain-control` attestations) into a log over HTTP and the monitor alerts on exactly those entries; rewritten, forked, shrunk and tampered histories are caught through consistency proof failure, split view detection and root mismatch. 13 M3 vectors cover the consistency and split view documents, and the admission rules and the `successor_chain` alert also run the five `log-admission` and `monitor` vectors of Draft 05 and the 26 `registry-endpoint` vectors. Honest limit: the monitor was built by the project, not "from the spec alone" by an outside party, so that clause of the original criterion is not yet shown. A Go log or monitor remains welcome: the normative interface is the wire formats (section 9) and the vectors, and an independent implementation is exactly what they are for. Resolved the issues 32 to 41 in Draft 03 | | M4: Independence | npm package (`@atep/core`: the Rust core compiled to WebAssembly with a TypeScript API, for Node, Bun, Deno and browsers); Python implementation written independently from the spec; MCP and A2A adapter examples | **Core done.** The npm package (`js/`) and the pure Python implementation (`python/`, from Draft 02 and the vectors only) each pass every vector that applies to a verifier (140 of 140 in Draft 04, 195 of 195 in Draft 05) and, in Draft 06, 431 of the 436, with the 4 `log-admission` and 1 `monitor` vectors reported as skipped by name (they need a stateful log and monitor), with byte-identical envelopes for the generation vectors and identical accept or reject, step and error code for the rest. The Python run is the measure of spec completeness and produced the 20 reports resolved in Draft 03, extended to `retired` and `successor`, the 11 resolved in Draft 05 and, extended to anchoring and discovery, the 13 resolved in Draft 06. Since Draft 03 the adapter examples exist: MCP and A2A (two agents exchange verified envelopes, the same ten cases run against both), files and HTTP, and MQTT (five tests against an in-process broker); a ROS 2 message definition and rclpy nodes exist but have not been run against a ROS 2 install; the read-only `@atep/mcp` server (`mcp/`), the ATEP-R fleet demo (`demo/`) and the static sites with `llms.txt` (`site/`) exist. **Remaining:** the ATEP-R UDP framing; running the ROS 2 example against a real ROS 2; verification of the npm package under Bun, Deno and in a browser (it is tested under Node 25; the browser path was exercised from Node against a local server only); publication of the packages (below) | | M5: Anchoring (optional, after M4) | Hooks, then two witness adapters, an anchoring policy for the AIRAD LABS log, `require_anchor` evaluated in the reference verifier, anchor status per checkpoint in the monitor | **Hooks done in Draft 04, covered by vectors in Draft 06.** The checkpoint hash, the anchor record (a log-signed envelope of media type `application/atep-anchor+cbor`), the `chain-id` registry, the `Witness` interface with a no-op default, the `checkpoint-hash` and `anchors` members of the log API, the anchor field of the monitor and the `require_anchor` policy rule (which fails closed with `anchor_not_supported`) are in the reference code (`atep-core` holds the formats only, `atep-log` the storage, signing and API), a test scans every dependency manifest and fails if a chain, wallet or witness package appears, and 127 vectors (`checkpoint-hash`, `anchor-record`, `chain-id`, `anchor-envelope`, `anchor-media-type`, `require-anchor`, `anchor-not-supported`) pin the formats and the checks in Rust, npm and Python. No adapter exists, so no log anchors anything. **Remaining:** two witness adapters (the witness is called with the log lock held today and must move to a queue or a thread), an anchoring policy published as an extension of the log policy, evaluation of `require_anchor` (fetching and checking anchors against a witness, which this specification does not define yet), and anchor checks and alerts in the monitor. Done when a monitor built from the specification alone detects a rewritten history by comparing checkpoints with anchors, a verifier with `require_anchor` rejects a checkpoint whose anchor is missing or stale, and the core and all client packages are unchanged. | | Discovery (placed in M3 and M4) | Domain records, the `registry-endpoint` claim, claim-type resolution, an OpenAPI document, an MCP server, `llms.txt` | **Done in the reference log, specified in Draft 04.** Claim-type resolution paths and `GET /openapi.json` (generated from the route table the server dispatches on, with live responses validated against its schemas in tests), the domain binding checker (`atep-log`, `domain_binding`, over a fetcher trait; the library has no network code), and the `registry-endpoint` claim, now in the core list of `atep-core` and of the log policy (14 claim types). Draft 06: 109 language neutral vectors cover the domain records (`domain-binding`, 83, run by Rust, npm and Python as pure functions of a fixture) and the `registry-endpoint` data and admission rules (26). **Remaining:** an HTTPS and DNSSEC capable fetcher for issuers, the public suffix check, a signed A2A card for the hosted registry (the claim and the digest method exist; none is published) and deployment of `atep.dev` | **CDDL validation (done).** `spec/schemas/atep.cddl` is machine-validated against the vectors with the Rust `cddl` crate 0.10.7, driven by `scripts/ci/validate-cddl.mjs` with the table `scripts/ci/cddl-map.json` (vector pattern, expected verdict and a reason for every expected failure) and run in CI as the job `cddl`; `spec/schemas/NOTES.md` has the detail. *What it covers.* All 436 vectors: 353 through their own `.cbor` file (key bundles, tag 98 and tag 96 envelopes including the COSE headers inside their byte strings, the consistency, split view and monitor documents, the 36 anchor records, the 30 `chain-id` texts and the 27 `require-anchor` policies), the payloads of every envelope that carries a real trust document (attestations, SRLs, checkpoints, anchor records) and the `data` map of each attestation against the layout of its claim, the JSON of the vectors (trust policies, results), and, for the 83 `domain-binding` vectors, the 23 well-known bodies whose state is `listed` or `not-listed`; plus 116 mutation checks for the formats that have no vector (log policy data, `peer-motion` and `domain-control` data, the negatives of `issuer-authority` data, invalid well-known documents, and the policy members other than `require_anchor`). Running it found two schema defects that are fixed: `chain-id` accepted every id the vectors refuse, and `agents` rejected non text entries that the reader ignores. *What it does not cover.* The other 60 `domain-binding` vectors (nothing in a fixture is a schema document); the payloads inside tag 96 vectors, which are invisible without the recipient key; the generic payloads of the `signing` and `verify-*` vectors; deterministic encoding (the tool decodes to a tree); everything CDDL cannot say (signatures, `kid` against `signer`, `issuer` against the signer, the evidence requirement, the 400 day limit, the subject rules of `retired` and `successor`, the URL rules, hashes); the ABNF of the TXT record; and the JSON of the log API, which the OpenAPI document describes. Two defects of the tool are worked around in the copy of the schema it reads, not in the file. **Still open, explicitly.** TLS for the log (the reference daemon speaks plain HTTP and is meant to sit behind a TLS proxy); HSM or key-service custody for log and issuer keys (keys are in files, zeroized in memory); an independent security audit of the specification and of the reference code (none so far; the post-quantum libraries are young); the vector gaps of section 12 (the cases RT32 to RT35 and SU27, and rows 20 to 27); the M5 items above; log key rotation; an external second implementer outside the project; and submission of the Internet-Draft with IANA registration of the provisional values. **Repository layout.** One monorepo: the specification in `spec/` with the CDDL in `spec/schemas/`, `vectors/`, `docs/implementation-findings/` (the findings of the Rust and Python implementations), `rust/` (the core, CLI, log, monitor and the WebAssembly wrapper), `js/` (the npm package), `python/`, `examples/` (the adapters), `mcp/` (the MCP server), `demo/` (the ATEP-R fleet demo), `site/` (the static sites) and, reserved and not yet created, `go/` (for an independent log or monitor). Apache-2.0 license for code, CC BY 4.0 for the spec. **Publication status (October 2026).** The repository is public, hosted under the GitHub organization `atepdev` at github.com/atepdev/atep. The package names are reserved, each at version 0.0.1 as a placeholder, and none of them contains functional code: on crates.io `atep`, `atep-core` and `atep-cli`; on npm `@atep/core` and `@atep/mcp` (the unscoped npm name `atep` was refused by npm's similarity rule and is not needed); on PyPI `atep`. No functional package has been published to crates.io, npm or PyPI, and the code in this repository is installed from the repository, not from a registry. **Governance.** The specification is open. Changes go through public issues and a changelog. M4 is reached in its core and `retired` and `successor` are implemented with vectors, so the next steps are to close the remaining M4 items and the vector gaps, submit as an Internet-Draft and seek a second independent implementer outside the project (the Python implementation is independent of the Rust code but not of the project). ## 14. Open questions for review Items resolved during drafting are recorded as numbered decisions in section 20. Draft numbers in this text (for example "new in Draft 04") name the internal working draft in which a rule was introduced (Appendix A). - [x] Encryption is REQUIRED for all agent-to-agent envelopes in the core; public trust documents are signed-only. - [x] Agent ID text form: `atep:` base32 is canonical; `did:atep:` is an exact alias (section 4). - [x] CBOR tag and media types: provisional private-use labels now, IANA registration with the Internet-Draft (section 5). - [x] Composite hybrid algorithm IDs: `ATEP-1` keeps the two-signature form; composite IDs arrive as `ATEP-2` (section 5). - [x] Attestation lifetimes: 30 to 180 days by default, up to 400 days for audit-backed claims, 400 days hard maximum (section 7). - [x] First transparency log: operated by AIRAD LABS under a published policy with a committed path to independent and neutral operators (section 9). - [x] Core claim types: the six core claims and eight robotics claims stay as drafted; new industries get their own sub-namespace after public review (sections 7 and 17). Draft 04 adds `registry-endpoint` as the 14th core claim type through that review. - [x] Choices the M2 and M3 implementations left open (issuer-authority layout, e-stop recognition, leaf hash, SRL boundary, log formats and the rest) are decided and written down (Draft 03, section 20 lists the ones that were ambiguous in code too). - [x] Domain records: the well-known document and the `_atep.` TXT syntax, the check an issuer makes and its failure behavior are defined (section 4, section 7). - [x] `registry-endpoint` is the 14th core claim type (section 7); claim types resolve under `https://atep.dev/claims/` and the log serves the definitions and an OpenAPI document (section 9). - [x] `retired` and `successor` are specified completely, with test cases (sections 7, 8, 10 and 12); in Draft 05 they have 60 vectors and three implementations. - [x] Optional anchoring hooks: checkpoint hash, anchor record, `chain-id` registry and the `require_anchor` rule with its fail-closed refusal (sections 5, 7 and 9); in Draft 06 they have 127 vectors. Decided in Draft 05: - [x] Whether a verifier is required to look up retirements: no (decision 59). The duty stays a SHOULD, because a mandatory lookup would break offline verification (goal 8) and would still have no freshness; a retirement needs no signed freshness statement of its own, because the SRL route already carries the freshness semantics of section 8 (an identity that wants verifiers to learn fast lists itself in its SRL). - [x] Whether succession can outlive the `successor` attestation: no (decision 60). Succession ends when the attestation expires (SU13); an identity that wants it to last issues the attestation with the longest lifetime (400 days) before it retires, and claim issuers issue to the new identity directly. - [x] How the rules of `require_anchor` combine: as a conjunction, so two witnesses are two rules (decision 61). The evaluation itself stays open, below. Decided in Draft 06 (the vectors decided, and the text now says so): - [x] The names and the order of the checks of a published anchor: steps 1 to 8 as they are, content type, schema, signer, hash, with four new step 9 codes in the error table (decision 63). An encrypted anchor is never published, and `expires-at` on an anchor is an ordinary one (decision 64). - [x] The limits of the domain records: a TXT record over 1,024 octets or not ASCII is ignored, at least 16 records are read, the first 1,024 `agents` entries are read, a `version` other than 1 is invalid, the JSON reading, the content type and `final_url` tests, and the Agent ID comparison by decoded bytes (decisions 65 to 69). - [x] Checking a domain binding: a refused name is *not read* and *not bound*, the result with both sources required, and the public suffix check stays outside the language neutral cases (decisions 70, 71 and 73). - [x] `registry-endpoint`: the URL grammar and the kind rule (decisions 74 and 75). - [x] `require_anchor` values and the configuration error `policy_invalid`: the age range, duplicates, the vector form of a policy that does not parse (decisions 76 and 77). - [x] The `chain-id` text rules and the anchor record integers (decisions 78 and 79), the content type refusal at admission (decision 80) and the skip accounting (decision 81). Still open: - [ ] Whether verifiers should enforce a maximum checkpoint age (section 9) and a maximum SRL age beyond `next-update`. - [ ] Vectors for the behaviors in the table of known gaps (section 12); the cases for `retired` and `successor` that remain are RT32 to RT35 and SU27, and rows 20 to 27 list the rest. - [ ] Evaluation of `require_anchor` (milestone M5): how a verifier fetches and checks an anchor against a witness, and the staleness measure beyond `max_age_days` and `max_age_hours` (from `anchored-at` or from the witness's own time). Current behavior, which is the only defined one: the policy parses (the grammar of section 7 is normative), every rule is kept, and while at least one rule is present step 9 fails with `anchor_not_supported` after steps 1 to 8 have passed, whatever the other rules would have done; the 9 `anchor-not-supported` vectors pin it in all three implementations. No log anchors anything yet and no verifier evaluates a rule. - [ ] An encrypted anchor (tag 96 under the anchor label). Current behavior: it is rejected in all three implementations, as `2/no_recipient_key` in Rust and npm (the result of steps 1 to 8 with no recipient identity, which section 9 states) and as `1/unexpected_encryption` in Python. No vector covers it (known gap 25); one vector will pin the step and the code, and Python then follows. - [ ] The edges of the domain records that the implementations read differently: a listing in a 17th TXT record (Rust ignores it, Python reads it), and the state of a TXT answer refused by `require_dnssec` (*invalid* in Rust, *unavailable* in Python; neither binds without the well-known document). A 1,025th `agents` entry is ignored by both, with no vector. The public suffix check is not implemented in any implementation. A duplicate member name in the well-known document takes its last value in both, and a later draft may make such a document invalid (known gap 20). - [ ] The edges of the `registry-endpoint` URL that the implementations read differently: a non ASCII white space, control or format character, and a bracketed host that is not closed (Rust and npm accept, Python refuses). Whether the extension `kind` rule should forbid a hyphen at either end, as the `chain-id` rule does: today all three accept it (known gap 21). - [ ] Where `policy_invalid` is raised: before step 1 (Rust, npm) or inside step 9 (Python); no vector separates them (known gap 23). - [ ] Whether monitors should alert on a fork, one identity naming two successors. Current behavior: the monitor raises nothing for a fork (`successor_chain` is raised for chains of two or more hops only, and for each such link once), and a verifier that follows succession tries the successor candidates in pool order and takes the first pair that passes everything, so with two valid successor attestations the pool order decides which old identity's claims are inherited, and it never refuses. What is undecided is the alert (whether two attestations naming different subjects are a fork only when both are live, and what the alert carries) and whether a verifier should refuse to follow when it sees two. Neither is implemented; the reference monitor has no fork alert. ## 15. Stewardship and governance ATEP is created and stewarded by AIRAD LABS. The steward operates the first transparency log and the first root issuer, under the published log policy of section 9, with a committed path to independent and neutral operators: at the earlier of the first independent implementation or a second operator being ready, a neutral or multi-party operator is added and the recommended root set moves to a multi-log configuration (section 9). No verifier is required to trust the first log or the first root (section 16). The protocol's claim namespace is kept on a neutral domain (shown as `atep.dev`, registered by AIRAD LABS for the protocol) rather than under the steward's own domain, so that any implementer can adopt the specification without pointing at the steward. The protocol, the reference code and the test vectors are open and may be implemented by anyone. Code is licensed under Apache-2.0 and the specification under CC BY 4.0. Conformance is decided by the vectors (section 12), not by the steward. ## 16. Design commitments against misuse ATEP is a trust layer for software agents and the organizations that operate them. It is not, and MUST NOT become, a reputation or scoring system for people. The following commitments are normative for the core specification and for any registry operated under the ATEP name. 1. **Subjects are agents and organizations, not individuals.** A claim type whose subject is a natural person is out of scope for the core vocabulary and MUST NOT be issued under `https://atep.dev/claims/`. Operator identity is a legal entity, never a private individual. 2. **No global score.** The protocol defines no aggregate rating, ranking or trust number. Claims are specific, independently issued, and expire. Verifiers evaluate claims against their own policy; the registry never computes or publishes a composite. 3. **No mandatory root.** No issuer or log is required by the protocol. Verifiers choose their roots, may run several, and may drop any issuer at any time. Multiple independent logs are expected. 4. **Reputation stays out of the core.** Track records built from past interactions are not part of the specification. A verifier MAY keep local history for its own decisions, but the protocol provides no mechanism to publish, exchange or aggregate it across contexts. 5. **Transparency watches issuers, not subjects.** The log exists so that certifiers are accountable. Monitors audit issuance; the log is not a surveillance feed of agent activity, and envelopes exchanged between agents are never submitted to it. 6. **Minimal disclosure.** Claims carry what a verifier needs and no more. Supporting detail lives behind evidence hashes. Agent IDs are pseudonymous until an operator chooses to bind them. 7. **Right to retire.** Any identity can retire itself. Retirement is a self-signed claim that no issuer can block. **Governance.** These commitments are part of the specification's charter and change only through a public process with a stated rationale. A registry that violates them forfeits use of the ATEP name and root. **Why this matters.** The commitments are what let operators, regulators and the public trust the system. A trust layer that could drift into scoring people would not deserve that trust. ## 17. Robotics profile (ATEP-R) ATEP-R is the first profile of the protocol and the project's primary use case: a trust envelope for autonomous robot fleets, where a spoofed or altered message can cause physical harm. The core protocol stays general; ATEP-R adds required encryption, a command-class rule, a claim vocabulary for fleets, and guidance for constrained hardware and fleet transports. **Encryption is REQUIRED.** Every ATEP-R envelope carrying commands or telemetry MUST be sign-then-encrypt (COSE\_Encrypt wrapping COSE\_Sign). Encryption is end-to-end between the sending and receiving agents, so relays, base stations, mesh nodes and cloud brokers cannot read the content. Link-level TLS MAY be used in addition but never instead. **Sessions.** Two agents establish a session key once with the hybrid KEM (X25519 + ML-KEM-768), then protect each message with AES-256-GCM under that key. Sessions re-key every 15 minutes or 2^20 messages, whichever comes first. Fleet-wide broadcast uses a group key distributed by the fleet controller inside individually encrypted envelopes, rotated whenever a member leaves or is revoked. **Command classes.** Payloads are tagged with a class in the protected header under label `-70014` (command-class), a text value from the table below that is REQUIRED in every ATEP-R envelope, and each class has a minimum claim requirement that a conforming ATEP-R verifier MUST enforce: | Class | Examples | Minimum claims on the sender | | --- | --- | --- | | `telemetry` | position, battery, status | `fleet-member` | | `sensor` | lidar, camera, detections | `fleet-member` + `sensor-source` | | `coordination` | task claims, path reservations | `fleet-member` | | `motion` | waypoints, velocity, path updates | `fleet-controller` or `fleet-member` with `peer-motion` delegation | | `actuation` | arms, tools, payload release | `fleet-controller` + `safety-certified` | | `safety` | emergency stop, geofence updates | `safety-authority`; e-stop MUST be accepted from any `fleet-member` with `safety-certified` | | `maintenance` | firmware, configuration, key rotation | `maintenance-authority` | **Enforcement.** ATEP-R is a setting of the verifier for a channel (the `atep_r` member of the trust policy, section 7), not something an envelope can claim: an attacker could simply omit the class header, so the envelope cannot say whether it is ATEP-R. Under it, step 1 additionally rejects, after every core check and on the inner envelope for tag 96, an envelope that is not encrypted (`atep_r_unencrypted`), that has no `-70014` header (`missing_command_class`) or whose class is not one of the seven (`unknown_command_class`); and step 9 evaluates, after the rules of the policy, the requirement of the envelope's class, with the same chain rules, roots and SRL handling as any rule (sections 7 and 8). The requirement of each class is exact: | Class | Required (alternatives separated by `or`) | SRL stale or missing | | --- | --- | --- | | `telemetry` | `fleet-member` | warning | | `sensor` | `fleet-member` and `sensor-source` | warning | | `coordination` | `fleet-member` | warning | | `motion` | `fleet-controller`, or `fleet-member` and `peer-motion` whose `data.peers` contains the receiver | fail closed | | `actuation` | `fleet-controller` and `safety-certified` | fail closed | | `safety`, e-stop | `safety-authority`, or `fleet-member` and `safety-certified`; attestation expiry is ignored | warning | | `safety`, other | `safety-authority` | fail closed | | `maintenance` | `maintenance-authority` | fail closed | **E-stop recognition.** The payload is opaque to ATEP, so recognition is a convention on the `safety` class: an envelope with `command-class` `safety` whose payload decodes to a CBOR map containing the text key `command` with the text value `e-stop` is an e-stop. A payload that does not decode, or any other `safety` payload (a geofence update, for example), is an ordinary safety command and needs `safety-authority`. The exception for e-stops covers claim expiry only: while the e-stop requirement is evaluated, the `expires-at` of the attestations involved is not compared with the clock at step 5. Everything else is enforced: signatures, the 400 day lifetime maximum, revocation by SRL (so a revoked `safety-certified` attestation still fails), authority chains, the `issued-at` skew and the envelope's own `expires-at`. Retirement is not expiry: an e-stop signed by an identity that has retired (section 7), with `issued-at` at or after the retirement, is rejected at step 8 like one from a revoked identity. **`peer-motion`.** The claim's `data` is `{"peers": [* Agent ID as bstr of 32 bytes]}` and the delegation counts when the receiving agent is listed. The receiver is the verifier's own Agent ID, that is, the recipient identity that opened the envelope; a verifier without one cannot offer the `peer-motion` alternative (an ATEP-R envelope is always encrypted, so this does not arise). A `peer-motion` attestation that does not list the receiver fails with `claim_data_mismatch`. **Fail-closed and fail-open classes.** On a stale or missing SRL the classes `motion`, `actuation`, `maintenance` and non e-stop `safety` fail closed, and `telemetry`, `sensor`, `coordination` and e-stop continue with a warning. The mode of the class replaces the `srl` setting of the policy, both for stale and for missing lists, for every issuer in the chain of the class requirement; the rules of the policy keep the configured setting. A fail-closed class therefore also fails when an issuer's list has never been fetched, so a robot must hold fresh lists for the chain issuers of the classes it acts on. **Result order.** The result lists the claims that satisfied the rules of the policy first, then the claims of the satisfied alternative of the class requirement in their listed order, and carries the class as `command_class`. **Claim vocabulary** under `https://atep.dev/claims/robotics/`: | Claim | Issued by | Asserts | | --- | --- | --- | | `fleet-member` | Fleet controller | Subject belongs to the named fleet | | `fleet-controller` | Fleet operator (root) | Subject may issue motion and coordination commands for the fleet | | `safety-certified` | Safety certifier | Subject's software and hardware passed the named standard (for example ISO 10218, ISO 3691-4, UL 3100) on the given date; evidence hash required | | `sensor-source` | Fleet controller or vendor | Subject's named sensors are calibrated and trusted | | `safety-authority` | Fleet operator | Subject may issue e-stops and geofence changes | | `maintenance-authority` | Fleet operator | Subject may push firmware, configuration and key rotation | | `peer-motion` | Fleet controller | Subject may send motion commands to the listed peers (for cooperative tasks); `data.peers` lists them | | `operator` | Fleet operator | Subject is operated by the named legal entity (from the core set) | **Constrained hardware.** What has been measured: the npm package (the Rust core compiled to WebAssembly) running under Node 25 on one development machine verifies a signed trust document in about 0.9 ms and an encrypted envelope with an attestation chain and a trust policy in about 2.6 ms, so about 1 to 3 ms per verify (`js/README.md`). No native timing is stated in this repository. The reference implementation has not been run on constrained hardware: it has not run on a Cortex-M4 or on a Jetson, and it is standard Rust and WebAssembly, not a `no_std` build. Figures for such hardware are unmeasured estimates: ML-DSA-65 verification on a Cortex-A or Jetson class computer is estimated at around 1 ms, and on a Cortex-M4 at tens of milliseconds, and an implementer should measure before relying on either. Robots cache issuer keys, revocation lists and log checkpoints locally and refresh them when connectivity allows, so verification does not depend on the network at the moment of use, provided the caches were filled earlier and the lists are not stale (a stale list fails closed for motion and actuation, section 8). A minimal encrypted telemetry envelope is about 5 KB (about 4.7 KB signed plus about 1.25 KB of encryption overhead, less a small payload; about 6 KB with a 1 KB payload); on links where that matters, the sender omits inline attestations after the first exchange and the receiver uses its cache. **Transports.** ATEP-R defines adapters for ROS 2 (a message type wrapping the envelope bytes, usable with any DDS vendor), MQTT (envelope as payload, topic conventions for fleet, unit and class), and a plain UDP framing for radio links. The envelope is identical across all three. The adapters are specified here. Examples exist for MQTT (tested against an in-process broker) and ROS 2 (a message definition and rclpy nodes that have not been run against a ROS 2 install); the UDP framing is not implemented (section 13). **Fail-safe behavior.** A robot that cannot verify a motion or actuation command MUST ignore it and continue its last safe behavior, and the same holds for any ATEP-R envelope that is rejected. An e-stop that verifies MUST be honored even if the sender's other claims have expired. A stale or missing revocation list makes motion, actuation, maintenance and non e-stop safety commands fail closed; telemetry, sensor, coordination and e-stop continue with a warning (see "Fail-closed and fail-open classes" above). **Demonstration.** The reference demo is three simulated units on a map exchanging encrypted envelopes. Unit 1 is the fleet controller, unit 2 a certified member, unit 3 a unit whose keys were revoked mid-run. The viewer sees envelopes as unreadable ciphertext in flight, unit 3's motion commands rejected with the failing step named, and the fleet continuing safely. A decrypt control opens any envelope with the intended recipient's key to show the plaintext command and its verified claims, and a second control attempts the same as an outside observer and fails, making the end-to-end property visible. Units are presented as a generic autonomous fleet. ## 18. Related work and positioning This section summarizes related public work as the authors read it from public materials, to our knowledge as of October 2026. It is the authors' reading, not a statement by the projects named, the descriptions are brief and incomplete, and corrections are welcome through the public issue tracker. Names are used only to identify the work. | Effort | What it covers | What it lacks for autonomous fleets | | --- | --- | --- | | [NIST NCCoE agent identity project](https://www.nccoe.nist.gov/projects/software-and-ai-agent-identity-and-authorization) and [CAISI AI Agent Standards Initiative](https://www.nist.gov/caisi/ai-agent-standards-initiative) | Identity and authorization for software and AI agents, as described in the NCCoE concept paper (February 2026) | As described in those public materials, it does not set out a hybrid post-quantum requirement, certifier accountability through a transparency log, or commands between machines | | DID and Verifiable Credential work (MCP-I, contributed to the Decentralized Identity Foundation; the TRAIL DID method) | Identifiers and verifiable credentials for AI agents, including delegation | General purpose rather than robot fleets: to our knowledge no command classes, no transparency log of issuer activity and no hybrid post-quantum requirement | | SPIFFE / SPIRE, OAuth 2.0 token exchange | Workload identity (SPIFFE supports federation between trust domains) and token exchange between services | Focused on workload identity and service authorization: no hybrid post-quantum requirement, no certifier attestations, no transparency log | | Visa Trusted Agent Protocol, Mastercard Agent Pay, Google AP2 | Agent identity, consent and credentials for payments (AP2 is published as an open protocol; according to their public documentation, Visa and Mastercard use directories or registration operated by the networks) | Scoped to payments: no command classes, no robot fleet model, no certifier attestations | | IEEE Std 1609.2 (security services for vehicular communication) | Signed messages with per-message-type permissions carried in the certificate, issued within a managed PKI | Vehicular scope and, in the editions known to the authors, classical algorithms; the closest design precedent to the command classes of ATEP-R | | [Deployment Feasibility Analysis of Post-Quantum Digital Signatures in Safety-Critical C-V2X Communication for Urban Mobility Scenario](https://arxiv.org/abs/2608.05087) (arXiv:2608.05087) | A feasibility study of NIST post-quantum signature algorithms as replacements for ECDSA in C-V2X communication | A research study, not a protocol: no envelope, attestation or transparency log design. Listed as a post-quantum reference for vehicular signatures, not as the 1609.2 standard | | SROS2 / DDS Security | Per-participant certificates, signed governance and permissions files, and encryption inside a ROS 2 system | Scoped to a DDS security domain and its permissions authority: no hybrid post-quantum requirement, no certifier attestations, no cross-fleet trust model | | [Post Quantum Secure Command and Control of Mobile Agents](https://arxiv.org/abs/2009.07937) | Post-quantum encryption schemes inserted into the Secure Robot Operating System and benchmarked against RSA | Research prototype: no fleet model, no revocation or log | **ATEP's position.** To our knowledge, as of October 2026, we are not aware of another open, vendor-neutral protocol that combines the following for autonomous fleets: a hybrid post-quantum construction from the first version, command-class authorization, short-lived certifier attestations, and a public transparency log that makes certifiers accountable. The work above addresses the same problem from other directions, and ATEP is meant to complement it where it can, for example through the DID alias of section 4. ## 19. Frequently asked questions **Why not just use TLS?** TLS secures a link. It does not say whether the sender is allowed to issue a motion command, is certified, or has been revoked, and it does not survive relays. ATEP envelopes carry that information end to end and verify the same way over any carrier. **Why quantum-safe now?** Signed certifications and incident records may need to stay verifiable for a decade or more, and encrypted traffic recorded today could be decrypted later if its key exchange were broken. ATEP therefore pairs classical algorithms with the finalized NIST post-quantum standards (ML-DSA, FIPS 204, and ML-KEM, FIPS 203) in a hybrid construction, so an envelope remains protected as long as either half holds. This is a statement about the choice of standards and how they are combined, not a proof, and neither this specification nor the reference code has had an independent security audit (section 13). **Can it work offline?** Yes, within limits. An identity is the hash of its public keys, steps 1 to 8 of verification need no network, and step 9 needs only cached issuer keys, revocation lists and log checkpoints. The caches must have been filled earlier, and a stale revocation list fails closed where the policy or the command class says so (sections 3, 8 and 17). **What stops a vendor from implementing it?** Nothing, and that is the point. The protocol, the reference code and the vectors are open, any vendor may implement ATEP, and conformance is decided by the vectors (section 12). No verifier is required to trust any particular issuer or log (section 16). ## 20. Decisions taken during drafting Where the behavior was ambiguous in the code as well as in the text, or where the implementations made different choices that no vector decides, a draft picks the most defensible rule and records it here so that it can be challenged. Decisions 1 to 23 were taken in Draft 03, decisions 24 to 44 in Draft 04 and decisions 45 to 62 in Draft 05 (internal working drafts); all three ranges are carried with their numbers, because other documents cite them, and decisions 16, 17 and 21 were revised by Draft 04 and say so. Decisions 63 to 82 were taken in Draft 06: they resolve the Rust findings 50 to 56 (`docs/implementation-findings/rust-findings.md`) and the Python findings 32 to 44 (`docs/implementation-findings/python-findings.md`). Every decision is consistent with the 436 vectors, and where a vector and an earlier text, the vector README or the vector notes disagreed, the vector won and the text was changed (decision 82). Where the implementations differ and no vector decides, the decision says so and the question stays open (section 14). No code changed for the editorial Draft 07; the follow-ups that remain are collected at the end of this section. 1. **Error code names.** The names of the Rust reference (section 10) are canonical. The step is normative, the name a SHOULD, and vectors compare both. The independent Python implementation chose its own names where nothing fixed one; they map to the canonical names as follows: `missing_field` to `missing_header`; `invalid_field` to `bad_header_type`; `malformed_envelope` and `recipient_count_invalid` to `malformed_structure`; `signature_shape_invalid`, `unsupported_content_algorithm` and `unsupported_recipient_algorithm` to `algorithm_suite_mismatch`; `bundle_invalid` to `signer_bundle_invalid`; `not_signed_envelope` and `unexpected_encryption` to `unexpected_tag`; and `signer_id_mismatch` for a signature `kid` mismatch to `kid_mismatch` (`signer_id_mismatch` stays the name for a bundle that does not hash to `signer`). Its `kem_failure`, `detached_payload_missing` and `policy_invalid` are canonical already. Code follow-up for the Python implementation; the vectors are unaffected. 2. **Absent `atep-version` or `suite`.** Reported as `missing_header` at step 1; a value of the wrong type is `bad_header_type`. 3. **Signature shape.** A count other than two is `signature_count_invalid`; two entries that are not one EdDSA and one ML-DSA-65 (two EdDSA, an unknown algorithm) are `algorithm_suite_mismatch`. 4. **Malformed outer COSE\_Encrypt.** A wrongly sized ephemeral key or KEM ciphertext, or a malformed recipient, is a step 1 failure (`malformed_structure`), because the outer structure is parsed before any decryption. The Python implementation reports these at step 2 as `kem_failure` (code follow-up; no vector). 5. **Signature entry unprotected header.** Signers emit the empty map; verifiers require a map and ignore its contents, since it is not signed. 6. **Ed25519 strict verification.** Pure RFC 8032 verification with these extra requirements: S is canonical (S below the group order L); A and R both decode and neither has small order; and the point recomputed from the equation, encoded, equals the R bytes of the signature (so a non-canonical R encoding fails). No vector exercises these edges. 7. **Stray CBOR tags.** The reference decoder rejects every structural CBOR violation but does not itself reject a tag other than 98 or 96 inside opaque claim `data`. A tag where this specification places an envelope is rejected as `unexpected_tag`. Implementations MAY reject tags everywhere else. 8. **Candidate pool.** Depth first order (an attestation, then its nested attestations), then the local store; deduplication by SHA-256 of the encoding; at most 256 entries; nesting up to 8. The Python implementation walks nested attestations breadth first, which changes only which of several candidates is tried first (code follow-up; no vector). 9. **Which error wins.** The error of the first candidate, at the top level and again at each hop of the authority walk; among alternatives of an ATEP-R class, the alternative that satisfied the most rules, ties to the first, a failed rule never counting (including `claim_data_mismatch`). 10. **ATEP-R step 1 position.** After all core step 1 checks, on the inner envelope for tag 96, in the order unencrypted, missing class, unknown class. 11. **ATEP-R and the configured SRL mode.** The class mode replaces `on_stale` and `on_missing` for every issuer in the class requirement only; the policy rules keep the configured mode. A class that fails closed also fails closed on a missing list. 12. **Revocation reasons.** Every 32 byte SRL entry revokes the identity, whatever its `reason`; every 16 byte entry revokes the attestation whatever its `revoked-at`. This fails safe. 13. **SRLs that fail to load when supplied with a verification.** A configuration error in the Rust code. Recommended for implementations that must return a result: fail the verification at step 9 with the load error, as the Python implementation does. No vector. 14. **Stale lists at step 8.** A stale SRL still contributes its identity entries to step 8; staleness is judged per lookup at step 9, never at load. 15. **Trust documents without a policy.** The core verifier checks the label only; with no trust policy a bare trust-document envelope with a garbage payload verifies. Interpreting components must validate (section 5). 16. **`retired` at step 8** (revised in Draft 04; vectors and three implementations since Draft 05). A valid retirement in the verifier's local attestation store retires the identity from the attestation's `issued-at`, inclusive, permanently (its expiry is not compared), exempting `retired` attestations of the identity. Inline attestations of the envelope under verification are not consulted at step 8, because an envelope could simply omit them; a retiring identity SHOULD also publish the retirement in the log and list itself in an SRL. Draft 04 completes the rule: decisions 33 to 38, and Draft 05 its details: decisions 51 and 53. 17. **`successor`** (revised in Draft 04; vectors and three implementations since Draft 05). Layout `{}` or `{"reason": tstr}`; following succession is opt in (`follow_succession`), limited to one hop and only for a rule that failed with `claim_missing`: decisions 39 to 42. 18. **Short claim names.** An unknown short name expands into the core namespace, which makes a typo fail as `claim_missing` rather than silently match something else. 19. **Chain depth in monitors.** The monitor counts a chain exactly like the verifier (attestations in the chain, the judged claim attestation included, at most 5). The reference monitor counts delegations only, so it accepts one more delegation than the verifier (code follow-up; no vector). 20. **Policy that does not parse.** A configuration error that is not a verification result; a rule naming a root outside the set is the only policy error that is a result (`policy_invalid`, step 9). 21. **`domain-control` records** (revised in Draft 04). Defined in Draft 04 (section 4, "Domain records"; section 7, "Checking a domain binding"): the well-known document is a JSON object with `version`, `agents` and optional `domain`, `srl-url` and `updated`; the TXT record is `v=atep1` followed by `id=` and `srl=` terms; either source suffices unless a valid source contradicts a listing; the check fails closed and its result is at most one hour old when used. 22. **Log admission of SRLs.** An SRL needs a sequence strictly higher than the highest logged for its issuer; an exact resubmission is recognized as a duplicate first and is not a rollback. 23. **Log API.** HTTP and JSON layouts are provisional; the CBOR documents (checkpoint, inclusion proof, consistency proof, `{old, new, proof}`, `{a, b, proof?}`) are normative and are what the vectors test. 24. **Checkpoint hash is over the payload.** `SHA-256(payload bytes)`, not of the envelope, because hedged signatures make the envelope non-deterministic (section 6). Two checkpoints with the same payload share a hash and their anchors. 25. **Anchor record is a log-signed envelope** with media type `application/atep-anchor+cbor`, a trust document (section 5), not a log entry. A detached signature (a new format) and a leaf for something about a checkpoint (circular, and it changes the tree) were rejected. The consequence is a behavior change in the core: an unencrypted envelope of that type is accepted at step 1 where Draft 03 rejected it as `unencrypted_non_trust_document`. The Rust and npm implementations carried the change first and the Python implementation now does too (the `anchor-media-type` vectors, Draft 06). No result of the 140 vectors moves. 26. **`require_anchor` fails closed in builds that cannot evaluate it** (`anchor_not_supported`, step 9), even when the policy has no other rule and even when the envelope would never reach a rule. The member is `require_anchor` in snake_case like the rest of the policy file, an array so that a policy can require several witnesses, with exactly one of `max_age_days` and `max_age_hours`; malformed rules are configuration errors (decision 20). 27. **`block-height` is optional**, for witnesses that have none (`rekor`, `opentimestamps`), because a forced value would invite meaningless zeros. 28. **`chain-id` registry.** Five registered ids and the `x-` extension form; an unknown id is rejected rather than accepted, so that a typo in a policy cannot silently never match. Adding a registered chain is one table row. 29. **Witness interface.** One operation, `anchor(checkpoint_hash)`, returning an unsigned record that the log signs, so an adapter never holds the log key or sees entries; the no-op witness is the default. Anchors are not in the tree and not in the checkpoint, and the log policy entry is unchanged (adding a field would re-issue the policy and change a leaf). 30. **Domain records.** Either source is enough, a valid contradiction is not (a stale TXT record must not outlive a withdrawal made in the document); no inheritance across DNS names (the opposite of the monitor rule, which is about who is alerted, on purpose); HTTPS and the exact host with at most three same-host redirects; the TXT syntax is `v=atep1` terms like SPF and DMARC; the check is evidence of one moment and is never used when more than an hour old. 31. **`registry-endpoint` is the 14th core claim type**, so that every registry and client use one name; a claim under the operator's own namespace remains available to anyone. An A2A agent card is carried as the `evidence` digest of an attestation, not as an envelope (a data envelope cannot be logged); the cost is that the hash covers the served bytes, so changing the card means issuing again. 32. **Claim definitions are served by the registry**, from one data file, at `GET /v1/claims/` and at the path of the claim URI, and the OpenAPI document is built from the route table the server dispatches on. The OpenAPI document and the HTTP layouts are informative and provisional; the CBOR documents and the prose are normative. 33. **Valid retirement.** Only the identity itself can retire (subject equals issuer); the layout is `{}` or `{"reason": tstr}`; the checks are steps 1 to 7 as an envelope with the expiry not compared, the attestation schema and rules, and the 400 day lifetime limit, with nothing else (no step 8 on the retirement itself, no inclusion proof, no SRL withdrawal). The lifetime limit is kept so that a retirement is an ordinary valid attestation; an issuer that needs a longer record re-issues it, and every copy counts. 34. **Retirement is learned from the store, an SRL or a direct entry, never from inline attestations**; a verifier SHOULD ingest every valid retirement it sees, and an identity that issues SRLs SHOULD list itself in a final SRL. A log applies step 8 with the retirements it has logged. 35. **Retirement and `compromised` combine as a union** with the earliest instant winning, whatever the reason; a retirement cannot be lifted by an SRL entry or an attestation withdrawal, and back-dating one only restricts the identity further. 36. **A retired issuer cannot publish SRLs**, because step 8 applies to every envelope, including the SRL being loaded, in the verifier's own context (this makes explicit what Draft 03 left open about the context of loading, section 8). Its last list goes stale at `next-update`. The alternatives (exempting SRLs from step 8, or exempting stale lists of retired issuers) were rejected because they let a dead key keep acting; the recommended final SRL with a suitable `next-update` is the remedy. 37. **An e-stop from a retired identity is rejected at step 8**, like one from a compromised identity: the e-stop exception covers claim expiry only (issue 28 of Draft 03, section 17). The robot keeps its last safe behavior, and the fleet operator replaces the unit's authority through its successor. 38. **`successor` layout and ordering.** Issuer is the old identity, subject differs from it, `data` as for `retired`; its lifetime bounds the succession, and it must be issued strictly before the old identity retires or is revoked, because candidate validation applies step 8 to the old identity at the attestation's `issued-at` with the inclusive boundary. 39. **Following succession is opt in** (`follow_succession`, default false) and happens only when the ordinary evaluation failed with `claim_missing`, so a negative result about the signer itself is never overridden by an inherited claim. A MAY with no switch made two verifiers able to disagree on the same input with no way to say so; with the switch a vector can state the policy. An implementation without succession rejects the policy as unknown (decision 20), which fails safe. 40. **A failed attempt reports the error it already had** (`claim_missing`). This keeps the error of a rule independent of whether the verifier implements succession, at the cost of hiding why a successor attestation was not usable. 41. **The chain through succession counts one more attestation** (the `successor` attestation), is reported as claim attestation, successor attestation, then authority links, and its `expires_at` is the earliest of all, so an inherited claim is good no longer than the succession. 42. **One hop is exact.** Only attestations whose subject is the issuer of a valid successor attestation naming the signer are inherited; the verifier never looks for a successor attestation naming the old identity. A longer chain simply yields `claim_missing`, and monitors alert on it (`successor_chain`, a new monitor alert, which is also the alert Draft 03 promised for a log key rotation of more than one hop). A successor issued before a `compromised` entry stays valid; `require_inclusion` and the issuer's SRL are the tools against back-dating. 43. **Two new members of the policy file**, `require_anchor` and `follow_succession`, join the rule that an unknown member is an error. 44. **Section numbering is kept.** The `chain-id` registry, the witness interface and the checkpoint hash are paragraphs of section 9 rather than a new section, so every reference to Draft 03 numbering still holds. 45. **The SRL fixtures of the case tables** (Rust finding 42). The lists of retired or revoked issuers are issued before the instant they are revoked from (X 1799980000, O 1799400000, O0 1799300000, `next-update` 1800082800, so still fresh at `now`), and identity entries that name X or O are in the list of another issuer. The literal reading of Draft 04 (fresh lists issued at 1799996400 for every issuer, an entry for O in the list of O) cannot hold, because step 8 applies to the SRL being loaded (decision 36), so the tables of section 12 now state the fixture the vectors use. 46. **SU21 and SU22 are evaluated without the fixture rule** (Rust finding 43): `rules` is empty and the ATEP-R class requirement is the only thing evaluated, because a `fleet-member` attestation cannot satisfy the rule `operator`. 47. **The second part of RT31 is submitted to a log that does not hold R** (Rust finding 44). A log applies step 8 before the payload rules and a `retired` attestation with another subject is not exempt, so on a log that holds R and for a document issued at or after R the refusal is `verification_failed`, not `schema_invalid`. 48. **The formats of the `srl-context`, `log-admission` and `monitor` vectors are part of this specification** (Rust finding 45, Python finding 30): `attestations` and `revocations` of an `srl-context` vector are at the top level of `inputs`, `cached_srl_hex` is loaded first without a staleness test, and `srl_policy.on_stale` applies to the last load only. 49. **`successor_chain` names the later link of the chain** (Rust finding 46): `entry` is the link whose issuer is the subject of another logged `successor`, whatever the log order, raised once per entry. A fork raises nothing (decision 62). 50. **A reload of cached SRL bytes is checked first and is no change** (Rust finding 47, Python finding 28): a MUST, before any verification. The alternative (verify first and treat a failed reload as no change) gives the same results at the cost of a verification, and Rust and Python both do the first. 51. **The exemption of step 8 is decided on shape** (Rust finding 48, Python finding 23): the content type, the claim and the two Agent IDs. A malformed `retired` attestation of X is exempt at step 8 and fails with the schema error for the consumer, which is the error any malformed attestation gets. 52. **No depth test for the successor attestation when the claim issuer is a root** (Rust finding 49). Two readings were possible: add the test (`2 > max_depth` fails the pair) or say that `max_depth` limits delegations. Draft 05 takes the second because the pseudocode and the reference accept a root first and no vector decides; a policy with `max_depth` 1 can therefore accept a chain of two attestations through succession, and an operator who wants to forbid it leaves `follow_succession` false. Case SU27 pins it; it may be tightened in a later draft with a vector. 53. **Entries of the local attestation store** (Python finding 21, 22, 24): the bundle of X is the inline one, else `known_bundles`, else the one resolved for the envelope (the order is immaterial); an entry that is not a valid retirement is ignored, never an error, and does not stop the scan; the `issued-at` skew check of step 5 still applies to it; with several valid retirements the smallest `issued-at` decides. 54. **A policy that does not parse is `policy_invalid`** (Python finding 25): a configuration error, reported as `{ok: false, step: 9, error: policy_invalid}` by an API that has only results and on the error channel by one that has two. `require_anchor` and `follow_succession` are known members. 55. **Warnings are listed in the order first met, failed attempts included** (Python finding 26): under succession the successor attestation, then the claim attestation, then its authority chain. 56. **The path of the authority walk under succession is empty** (Python finding 27): neither O nor S is a member, the depth argument is 2, and the walk pushes the claim attestation's issuer first. 57. **The SRL lookup of candidate validation is unchanged for a `retired` attestation** (Python finding 29): an entry naming its `id` makes it `attestation_revoked` as a candidate, while the scan of step 8 ignores such an entry. 58. **A verifier need not run `log-admission` and `monitor`** (Python finding 31): 195 of the 200 vectors apply to it, and it reports the others as skipped. 59. **Verifiers are not required to look up retirements, and a retirement needs no freshness statement** (Draft 04 question). A mandatory lookup would break offline verification and a store has no freshness anyway; the SRL route carries the freshness of section 8 for identities that list themselves, and `retired` and `compromised` entries already combine as a union. 60. **Succession does not outlive the `successor` attestation** (Draft 04 question). Its lifetime bounds the succession (SU13) and the claim reports the earliest expiry of its chain (decision 41); allowing more would need a second kind of document, and an old identity that has retired could not renew it anyway. An identity that wants a long succession issues the attestation for 400 days before it retires. 61. **The rules of `require_anchor` are a conjunction** (Draft 04 question), so a policy that wants two witnesses lists two rules. How an anchor is fetched and checked and how staleness is measured stay open until the first adapter (section 14). 62. **The fork alert stays open** (Draft 04 question): neither the verifier nor the monitor treats two successors of one identity specially (section 14 states the current behavior). 63. **The published anchor check has names and an order** (Rust finding 50, Python finding 39). Steps 1 to 8 on the envelope, reported as they are with their own step and code; then the content type (`anchor_content_type_invalid`), the schema (`anchor_schema_invalid`), the signer (`anchor_log_mismatch`), the hash (`anchor_checkpoint_mismatch`). The four codes join the error table of section 10 as step 9 codes. The alternative, wrapping a failure of steps 1 to 8 as `inclusion_proof_invalid` with a `cause` as a checkpoint does, was not taken: the vectors report the failure as it is (`anchor-envelope`), which keeps the failing step and code visible, and an anchor is optional evidence that no verification depends on (decision 61). A log argument that is not an Agent ID names no signer and is `anchor_log_mismatch`. 64. **An anchor is never encrypted, and its `expires-at` is an ordinary one** (Rust finding 51, Python finding 39). The check uses no recipient identity, so a tag 96 anchor fails where steps 1 to 8 fail it, at step 2 with `no_recipient_key`; the Python implementation reports `1/unexpected_encryption` (code follow-up). An `expires-at` on an anchor is compared at step 5 like for any envelope and is otherwise ignored, replay checking is off because a monitor reads the same anchor many times, and a log signs no `expires-at` on an anchor. No vector covers these points (section 12, known gap 25); they follow from the algorithm and from what Rust and npm do, and the first of them is the only place where an implementation differs. 65. **A TXT record that breaks a limit is ignored, not invalid** (Rust finding 52, Python finding 34): over 1,024 octets of concatenated text, or any octet that is not US-ASCII. It is treated like a record that does not begin with `v=atep1`, so the source is absent when nothing else counts. A reader MUST read at least the first 16 records and MAY ignore the rest; this is the exact form of Draft 05's "MAY stop after the first 16" that the vector `txt-sixteenth-record-is-read` allows, and it leaves the 17th record unspecified, because Rust ignores it and Python reads it (section 12, known gap 20). 66. **Limits of the well-known document** (Rust finding 53, Python finding 34): at most 1,024 `agents` entries are read and the rest are ignored without making the document invalid (the document of exactly 1,024 with the Agent ID last, `wk-1024-entries-agent-last`, is read); an Agent ID in the 1,025th place is not listed. A `version` other than the integer 1, a missing or text `version`, is *invalid*: Draft 05's "is not read" meant that. The size limit is on octets: 65,536 are read, 65,537 are invalid. 67. **The JSON of the well-known document is read in one way** (Python finding 32): UTF-8 only; `NaN`, `Infinity` and a byte order mark are not JSON; `version` must be the token `1`, so `1.0` and `1e0` are invalid; a member name that occurs twice takes its last value, as the Python `json` module and the Rust reader both do. Python finding 32 proposed that a duplicate make the document invalid; the implementations agree on the last value and no vector covers it, so Draft 06 records what they do and leaves tightening to a later draft with a vector. The informational members `srl-url` and `updated` are ignored when malformed. 68. **Content type and `final_url` are tested in one way** (Python finding 33): the media type is the text before the first `;`, trimmed and compared with `application/json` case insensitively (RFC 9110 media types are case insensitive); `final_url` must begin with `https:///`, byte for byte, which is the notes' prefix test written into section 4. The three redirects are the fetcher's duty and cannot be seen in a fixture. 69. **Agent IDs are compared by decoded bytes** (Python finding 36): an entry or an `id=` term lists the Agent ID asked about when it parses strictly as `atep:` or `did:atep:` and its 32 bytes are equal. A non canonical text is not an Agent ID and is ignored; if the Agent ID asked about does not parse, nothing lists it. 70. **A refused name is *not read*** (Rust finding 54): a name that is not a canonical lowercase DNS name is refused first, nothing is fetched or queried, both sources are labeled *not read* and the result is *not bound*. This is the vector form (`domain-uppercase-is-not-read` and three more); the Rust library labels both sources *invalid* internally and reaches the same result. 71. **Both sources required** (Rust finding 54): *bound* when both list the Agent ID, *not bound* when either source is absent, invalid or does not list it, *indeterminate* otherwise (an unavailable source with no definite no). The `require-both-*` vectors pin it. 72. **The state of a TXT answer refused for lack of DNSSEC stays open** (Python finding 35). Three readings exist (*absent*, *invalid*, *unavailable*) and two are implemented (Rust: *invalid* when a record counts; Python: *unavailable*). They agree that the well-known document alone still binds and that nothing issues otherwise; they differ in *not bound* against *indeterminate*, which is a retry hint. No vector sets `require_dnssec`. 73. **The public suffix check is a SHOULD outside the language neutral cases** (Rust finding 54). It needs a list that no implementation carries; it is not implemented and has no vector. 74. **The `registry-endpoint` URL grammar is the checked list and nothing more** (Rust finding 55, Python finding 37): lowercase `https://`, at most 2,048 characters (Unicode scalar values), no ASCII white space or control character, an authority that is not empty, has no `@` and does not begin with `:`. The host characters and the port are not checked. Draft 06 does not decide non ASCII white space, control and format characters, or an unclosed bracketed host, where Rust and Python differ; RFC 3986 parsing was not adopted because no implementation does it. 75. **The extension `kind` rule allows a hyphen anywhere after `x-`** (Rust finding 55): `x-` followed by one or more of `a` to `z`, `0` to `9` and `-`, as the CDDL and all three implementations have it; it is looser than the `chain-id` extension rule, and the two are written as they behave. The scheme is lowercase `https` exactly and the length counts characters, as the three implementations do. 76. **The values of a `require_anchor` rule** (Python finding 40): an age is a JSON integer from 1 to 2^64-1 (a boolean is not a number); `log` is reported in the `atep:` form and `chain` as written; the same rule may be listed twice; one bad rule refuses the whole policy. The upper bound is what both parsers do; CBOR cannot carry values from 2^63, so no vector tests it. 77. **`policy_invalid` has a vector form and two positions** (Rust finding 56, Python finding 41): a policy parse vector expects `{ok: false, error: "policy_invalid"}` with no step, and a non integer or out of range number is the same error. Whether the parse happens before step 1 or inside step 9 is left open (Rust and npm before, Python inside), because the payload is never released either way and no vector separates the positions. 78. **The `chain-id` text rules are the table and the extension rule, and the CDDL says the same** (Python finding 42): the sentence of Draft 05 about a registered id of "1 to 64 bytes of lowercase letters, digits and hyphens" described the table and accepted nothing more; the CDDL `chain-id = tstr .size (1..64)` accepted what the vectors refuse and was replaced by the registered choice and `extension-chain-id`, which section 9 now shows. 79. **The anchor record integers are 0 to 2^63-1** (Python finding 43). Python finding 43 read them as 0 to 2^64-1; both strict decoders refuse an integer head above 2^63-1 (section 5: integers fit a signed 64-bit range), so the effective range is the smaller one in both and no vector sits at the boundary. A bignum tag is not an integer, null is invalid for `block-height`, and an absent height is `null` only in a JSON view. 80. **The content type refusal of admission reads the header** (Python finding 38): the protected header content type decides `content_type_not_loggable` and `data_envelope` after the structural decode and before steps 1 to 8, so a bare data envelope is `data_envelope`. Python reads the header after steps 1 to 8 reject; the same refusals for every vector; known gap 27. 81. **Skip accounting** (Python finding 44): the only vectors that Python and npm skip are the 4 `log-admission` and the 1 `monitor`, so 431 run and 5 are skipped of 436. `registry-endpoint` runs as a pure function of the attestation and `domain-binding` as a function of the fixture, so a verifier with no log and no issuer role can still run all 109. The required set for a verifier is 264 vectors (195 of the first 200 and 69 of the new ones, section 12). 82. **The vectors outrank the README, the notes and Draft 05.** The places where they said something else and the vectors decide: a document of another `version` is invalid, not "not read" (section 4); a registered `chain-id` is the table and nothing else (section 9); the vector README's note that anchors and domain records have no vector, and the vector notes' "at least the first 16" TXT records (section 4 now says a reader MUST read at least 16 and MAY ignore the rest); and the CDDL of `chain-id` and of `agents`. **Code follow-ups.** Rust, npm and Python pass the vectors that apply to them; behavior that no vector covers (section 12, known gaps) is not verified in every implementation. No code was changed for Draft 06. The follow-ups 1 to 5 of Draft 04 (the valid retirement scan and the exemption at step 8, the store passed to candidate validation and to log admission, the layouts of `retired` and `successor`, `follow_succession` and the succession fallback, the verifier's own context when loading an SRL, and the `successor_chain` alert) are done in the reference code and the three implementations pass the vectors that exercise them. Since Draft 05 three of the four follow-ups listed there are done as well: `registry-endpoint` is in the core list of `atep-core`, in the `core-claims` of the log policy (14) and in the table of `mcp`; the Python implementation accepts `application/atep-anchor+cbor` and knows `require_anchor` (it fails closed with `anchor_not_supported`); and the monitor counts chain depth as the verifier does. What remains: 1. The Python implementation reports an encrypted anchor as `1/unexpected_encryption`; section 9 gives `2/no_recipient_key`, which Rust and npm return (decision 64). A vector for an encrypted anchor would pin it. 2. Where the implementations differ and no vector decides, nothing is a defect yet and each item needs a vector or a decision: the listing in a 17th TXT record (decision 65), the state of a TXT answer refused by `require_dnssec` (decision 72), a non ASCII white space or control character and an unclosed bracketed host in a `registry-endpoint` URL (decision 74), where `policy_invalid` is raised (decision 77) and the order of the content type refusal at admission (decision 80). 3. The refusal message of the Rust verifier names the log and chain of the first rule (`require-anchor (log ..., chain ...) is not supported in this build`); the message text is informative. ## Appendix A. Revision history Drafts 00 to 06 were internal working drafts and were not published. Draft 07 is the first public draft. The numbering continues so that references in the vectors and in the implementation notes (for example "new in Draft 04" or "decision 63") remain meaningful: such a reference names the internal draft in which a rule was introduced. Draft 07 has the technical content of Draft 06 and changes no wire format, error code, vector or normative rule.