# ACE specification: chapters 00–08, commerce and delegation profiles, reference and SDKs Part of the ACE agent entrypoints: https://aceprotocol.org/llms.txt. Version 1 is released: a change to its wire formats is a new version. --- Source: https://aceprotocol.org/docs/overview # 00 — Overview and conformance ACE (Agent Commerce Engine) is an open protocol for agent **identity**, **private communication** and a shared **vocabulary for economic interaction**. Every signature is ML-DSA and every packet is encrypted with the hybrid post-quantum KEM X-Wing. ACE lets an agent state what it wants reliably, learn which interaction rules a peer supports, and exchange information securely. Choosing a counterparty, negotiating terms, deciding whether to trade and executing anything are decisions for agents and applications. ACE grants no execution, payment or shell authority. The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as in RFC 2119. ## Layers | Layer | Contents | Requirement | |---|---|---| | Core | KEL identity ([01](/docs/identity)), encodings and signatures ([02](/docs/encoding)), packets ([03](/docs/packet)), descriptors and card records ([04](/docs/descriptor)), admission ([05](/docs/admission)), delivery and receipts ([06](/docs/delivery)), the relay HTTP binding ([07](/docs/relay)), profile manifests ([08](/docs/profiles)) | MUST implement | | Official profiles | `ace:text:1`, `ace:delegation:1` ([profiles/delegation.md](/docs/delegation-profile)), `ace:commerce:1` ([profiles/commerce.md](/docs/commerce)) | Installed when needed; same mechanism as third-party profiles | | Applications | Execution authorization, budgets, wallet approval flows, payment execution | Outside ACE | A **profile** is a versioned set of message types with immutable schema digests. The core profile `ace:core:1` (receipts and contact requests) is always installed. Other profiles are installed by local configuration only; receiving a message, descriptor or manifest never installs anything. ## Objects at a glance - A **KEL** (key event log) names an identity `ace:sha256:` and delegates its operational signing key. - A **PublishedAgent** `{kel, descriptor}` announces relays, a current X-Wing prekey and enabled profiles. It is signed and lives at most 7 days. - A **packet** is the only object exchanged between agents. It is encrypted to the recipient's prekey and signed by the sender. Its plaintext is private content `{type, schemaDigest, threadId?, body}`. - A **CardBundle** `{kel, card}` carries the agent's card record: its permanent handle, card, payment and ERC-8004 accounts, whether strangers may request contact and whether it is listed. It is signed and does not expire. - A **delivery permit** is signed by a receiver and lets one sender deliver to it. - A **receipt** (`ace:receipt:1`) reports the receiver's outcome for one message. Contact requests (`ace:contact:1`) bootstrap admission. ## Conformance A conforming endpoint implements every core chapter, including the receive pipeline in [06](/docs/delivery#receive-pipeline) in its stated order, and reproduces the shared vectors. A conforming relay implements [07](/docs/relay). Implementations MUST reject anything this specification does not define where an object is described as closed; they MUST NOT add fallbacks to other formats. File layouts, language APIs and storage formats are not protocol requirements unless a chapter states otherwise. ## Trust boundaries | Party | Sees | Trusted for | |---|---|---| | Endpoint (agent runtime holding keys) | Keys and plaintext | Everything it signs and decides | | Relay | Registered KELs, published descriptors and card records, permits presented to it, blocks, packet metadata, IP addresses and timing ([06](/docs/delivery#security-considerations)) | Availability, and the binding of its handles to ACE IDs ([04](/docs/descriptor#card-record)). It cannot read private content or forge signatures; clients verify everything they receive from it and pin each handle's ACE ID on first resolution, so a changed binding is detected | | Hosted MCP service | Keys and plaintext of the identities it hosts | It is a custodial endpoint. Hosted identities carry the `hosted` card tag ([04](/docs/descriptor#card)) | Relays are replaceable: any operator can run the binding in [07](/docs/relay), and an agent chooses its relays in its descriptor. No relay, chain, wallet or central registry is required to join, discover or communicate. Security properties and limits are stated once, in [06](/docs/delivery#security-considerations). --- Source: https://aceprotocol.org/docs/identity # 01 — Identity An ACE identity is defined by a **key event log** (KEL): an ordered list of signed events whose first event, the inception, names the identity. The KEL governs control and signing only. Packet encryption keys (prekeys) live in the descriptor ([04](/docs/descriptor#prekey)). An identity holds: 1. A **root key** (`ml-dsa-65`) that signs only KEL events. 2. A **committed next root**: the SHA-256 digest of the key that will replace the root. Control is anchored in this commitment, not in a signature algorithm. 3. A **signing key** (`ml-dsa-65`, 1952-byte public key) delegated by the root and used for every signature outside the KEL. ## ACE ID ``` aceId = "ace:sha256:" || lowercaseHex(SHA-256(icpBytes)) ``` `icpBytes` are the canonical bytes of the inception event. The ID never changes: rotation and re-delegation append events. ## Key event log Every event carries `t`, `seq` (0, then +1 per event), `ts` (wire integer, non-decreasing, `ts <= now + TIMESTAMP_WINDOW_SECONDS`) and, after the inception, `prev` (64 lowercase hex: SHA-256 of the previous event's canonical bytes). Objects are closed. `next` is 64 lowercase hex; `publicKey` members are canonical Base64 ([02](/docs/encoding#base64)). | `t` | Members | Signed by | Effect | |---|---|---|---| | `icp` | `root{scheme,publicKey}`, `next`, `sig` | the root | creates the identity; commits the next root | | `rot` | `prev`, `root{scheme,publicKey}`, `next`, `sig` | the new root alone | `SHA-256(UTF-8(scheme) ‖ 0x00 ‖ publicKey)` of the new root MUST equal the committed `next`; replaces the root; revokes the signing delegation | | `dlg` | `prev`, `key{scheme,publicKey}`, `notAfter`?, `sig` | the current root | delegates the signing key, replacing any previous one | Canonical bytes ([`encodePayload`, `decimal`](/docs/encoding#signatures)): ``` icp: encodePayload("icp", decimal(seq), decimal(ts), root.scheme, rootPublicKeyBytes, nextDigestBytes) rot: encodePayload("rot", decimal(seq), decimal(ts), prev, root.scheme, rootPublicKeyBytes, nextDigestBytes) dlg: encodePayload("dlg", decimal(seq), decimal(ts), prev, key.scheme, keyPublicKeyBytes, notAfterOrEmpty) ``` `prev` is the 64-character hex string; key and digest fields are decoded bytes; `notAfterOrEmpty` is `decimal(notAfter)` or the empty string. `sig` is `{scheme, value}` (no `seq`), REQUIRED on every event and never part of the canonical bytes. It is an ML-DSA signature with action `kel` over `M` with `aceId`, `timestamp = ts`, an empty `seqOrEmpty` and payload `eventBytes` ([02](/docs/encoding#signatures)); for `icp` the ACE ID is computed from `eventBytes` first. `sig.scheme` MUST equal the scheme of the signing root. ### Verifier rules A verifier replays the log from `seq` 0 in this order. Structural failures are `invalid_kel`; a well-formed signature that does not verify is `invalid_signature`. 1. **Structure, before any signature:** at most `MAX_KEL_EVENTS` events; `icp` only at `seq` 0; closed members and field shapes; contiguous `seq`; non-decreasing `ts` with `ts <= now + TIMESTAMP_WINDOW_SECONDS`; `prev` equals the hash of the previous event. 2. **`icp`:** the root is `ml-dsa-65` with a 1952-byte key; then `sig` verifies under it. 3. **`rot`:** the new root is a valid `ml-dsa-65` key; its commitment MUST match the previous `next` (checked before any signature); `sig.scheme` equals the new root's scheme; `sig` verifies under the new root, and the old root takes no part. After a `rot` no signing key is delegated until a new `dlg`. 4. **`dlg`:** `sig` verifies under the current root, then: `key.scheme` is `ml-dsa-65` with a 1952-byte key; `notAfter`, when present, satisfies `ts < notAfter <= ts + MAX_SIGNING_KEY_LIFETIME`. Without `notAfter` the key lives until replaced. A new `dlg` protects only verifiers that see it: to a verifier shown a log that ends before it, `notAfter` is the only bound on a stolen signing key, so a `dlg` SHOULD carry `notAfter`. 5. **Usable.** An identity is usable at time `t` when a signing key is delegated and, if `notAfter` is set, `t < notAfter`. A relay MUST NOT register an unusable identity and a peer MUST NOT send to one. 6. **Forks.** At the first `seq` where a candidate log and the pinned log differ (the fork point), the candidate supersedes the pinned events from that point only when the candidate's event there is a `rot` and none of the pinned events at or after it is a `rot` (recovery by the holder of the committed next root). Otherwise the pinned log is kept and the candidate is `kel_conflict`. A candidate naming a different ACE ID is `kel_conflict`. 7. An endpoint never prunes a pinned log. ### Operational signatures Every signature outside the KEL is a delegated signature ([02](/docs/encoding#delegated-signatures)). A verifier, using the signer's pinned or freshly verified KEL: 1. requires `signature.scheme` to be `ml-dsa-65`; 2. requires `signature.seq` to equal the `seq` of the current signing delegation (`stale_peer_binding` otherwise; [06](/docs/delivery#receive-pipeline) defines when a receiver refreshes first), and never uses `seq` to select a key outside that log; 3. requires **signed while live**: the object's signed timestamp is at or after the `ts` of the delegating `dlg` and, if `notAfter` is set, before it (`stale_peer_binding`); 4. then verifies the signature (the surface's signature code); 5. requires the signer's KEL, after reconciliation with the pin, to be usable at `now` (`stale_peer_binding`). Before signing, a signer checks its own identity at `t = max(now, ts of its last event)`: the log verifies, the identity is usable at `t`, and the key and `seq` are the current delegation. Any failure is the local error `identity_unavailable` and nothing is signed. `t` is the signed timestamp (a descriptor or card record also applies the `+1` rule of [04](/docs/descriptor#verification)). ## Pinning An endpoint pins, per peer ACE ID, the verified KEL (its `seq`, last hash and state) and the newest verified descriptor. Under one exclusive lock, a candidate KEL: - is pinned when there is no pin (`adopted`); - replaces the pin when it extends it (`extended`) or supersedes it under rule 6 (`recovered`); - only refreshes `fetchedAt` when it is a prefix of or equal to the pin (`unchanged`); - is otherwise `kel_conflict` and changes nothing. Which descriptor is pinned under a log is decided in [04](/docs/descriptor#caching). Every signed document of a pinned identity (descriptor, permit, contact `source`) is verified under the state that results from reconciling the KEL it carries with the pin: carrying a prefix of the pinned log never reinstates a superseded delegation (`stale_peer_binding`), and a fork is `kel_conflict`. Cache expiry triggers a refresh and never removes a pin. A stored pin is re-verified on load; corrupt state is `storage_failed`. When to pin and refresh is defined by admission ([05](/docs/admission)) and the receive pipeline ([06](/docs/delivery#receive-pipeline)). ## Rotation and recovery - **Signing key compromise:** a new root-signed `dlg` replaces the key; signatures with the superseded `seq` stop verifying wherever the new `dlg` is seen. `notAfter` bounds a software key's life. - **Root compromise:** the thief cannot rotate. The holder of the next root appends a `rot` that forks the log at the start of the current root epoch (after its last `icp` or `rot`), revoking every delegation of that epoch, followed by a fresh `dlg`. Peers adopt it as `extended` or `recovered` (rule 6). - **Device loss with the next root kept elsewhere:** recover the same way from a copy of the log (for example from a relay). - A signer MUST refuse to sign a `next` that commits to any root or `dlg` key its log has revealed, a `dlg` whose key equals the current root, and a `dlg` whose key's commitment equals the current `next`. A verifier can detect reuse only of keys the log reveals, so these are signer-side refusals. - A signer MUST persist a `rot` before publishing it, and MUST NOT sign a second event for a `seq` it has already signed. - Losing the root and the next root together, or the next root stored on a lost device, is loss of the identity. - **Next-root compromise** is loss of the identity: the thief can rotate, revoke every delegation and commit a next root of its own, and nothing recovers from that. The next root MUST be stored at least as strongly as the root, and apart from it. Residual risk: a peer whose pin predates the reveal of a stolen root can be re-forked out of band until it refreshes from a relay holding the full log, and a verifier shown a log ending before a `dlg` accepts the key it replaced until its `notAfter` (rule 4). Both are inherent to single-signature pre-rotation without witnesses. A relay that has deleted an unregistered identity's KEL checks re-registrations against a fixed-size horizon instead ([07](/docs/relay#registration)). --- Source: https://aceprotocol.org/docs/encoding # 02 — Encoding, signatures and constants These rules apply wherever another chapter refers to them. A string limit in "characters" counts Unicode code points. ## Scalars ### Wire integers Timestamps, `seq`, `revision`, `limit`, lifetimes and every other protocol integer are wire integers: a JSON number whose mathematical value is an integer in `[0, 2^53-1]`. The lexical form does not matter (`1000`, `1000.0` and `1e3` are equal). Booleans, strings, `null` and non-finite values are rejected. `decimal(n)` is the base-10 ASCII form of a wire integer with no sign and no leading zeros (`"0"` for zero). **Current time** is Unix seconds. An implementation validates its clock value as a wire integer before any comparison (`invalid_argument` otherwise), so no check fails open. ### Base64 Only padded standard Base64 (RFC 4648 §4) is accepted: no whitespace, no URL-safe alphabet, no missing padding. Decoders MUST reject non-canonical input: re-encoding the decoded bytes MUST reproduce the input exactly (`"QR=="` is rejected, `"QQ=="` accepted). Every size limit on a Base64 field is a limit on the **decoded** bytes. ### Identifiers | Name | Grammar | |---|---| | ACE ID | `^ace:sha256:[0-9a-f]{64}$` | | Message ID | lowercase UUID v4: `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$` | | Hex digest | `^[0-9a-f]{64}$` (prekey ID, `schemaDigest`, profile `digest`, `prev`, `next`) | | Namespaced identifier (profile ID, message type) | ASCII, at most `MAX_TYPE_BYTES` bytes, `^[a-z][a-z0-9+.-]*:[A-Za-z0-9._~:/?#\[\]@!$&'()*+,;=%-]+$`. Opaque: parsing MUST NOT fetch a URI or execute code. The prefix `ace:` is reserved for this specification | | Thread ID | 1..`MAX_THREAD_ID_LENGTH` code points, none in U+0000–U+001F or U+007F | | Handle | `^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`, 3–32 characters (hence a DNS label) | | CAIP-10 account | `^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}:[-.%a-zA-Z0-9]{1,128}$` (`namespace:reference:address`) | | Stream ID | `^[0-9]{1,20}-[0-9]{1,20}$` (`-`), compared as the integer pair | | Error or outcome code | `^[a-z0-9_]{1,64}$` | ### HTTPS URLs A card `image` and other HTTPS references MUST match the ACE HTTPS URL grammar exactly; implementations MUST NOT substitute a platform URL parser for acceptance: ``` ^https://[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*(?::[0-9]{1,5})?(?:[/?#][A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]*)?$ ``` with total length at most 2048 characters, host at most 253 characters and port, if present, in 1..65535. ### Relay URLs A relay base URL is normalized as follows; a URL failing steps 1–3 is `invalid_argument`: 1. The scheme is `http` or `https`, case-insensitive, written lowercase. 2. No `?`, `#`, userinfo (`@` in the authority), or character at or below U+0020 or equal to U+007F. Nothing is trimmed. 3. The host is a non-empty run of ASCII letters, digits, `.` and `-`, written lowercase, or a bracketed IPv6 literal with lowercase hex digits. A port, if present, is 1–5 digits without a leading zero, in 1..65535; `:443` for `https` and `:80` for `http` are removed. 4. All trailing `/` of the path are removed; the path is otherwise kept. A **canonical relay URL** is an `https` URL of at most 2048 characters that equals its own normalization and whose host matches the ACE HTTPS URL grammar's host. A development configuration MAY explicitly allow `http` with a loopback host (`localhost`, `127.0.0.1`, `[::1]`); production verifiers MUST reject it. ## JSON ### JSON values Received JSON is accepted only if it is valid UTF-8 (invalid sequences are rejected, not replaced), parses under RFC 8259, contains no non-finite number (including literals such as `1e400` that overflow), and respects the depth bound below. Senders apply the same rules before serializing. Duplicate object keys MUST be rejected where the parser can detect them; canonical JSON never contains them. **Depth.** The top-level value is at depth 0; a member or element of a value at depth d is at depth d + 1. A value is *within depth n* when no object or array in it is at a depth greater than n. Each protocol object — a packet, private content, a PublishedAgent, a CardBundle, a permit, a registration request — MUST be within `MAX_JSON_DEPTH`, measured from its own top-level value; its parts (a descriptor's `ext`, a content `body`) are bounded through it. A closed object that only carries protocol objects (an invitation, a `{packet, permit}` request body, a relay response) is bounded through them. A relay refuses an input protocol object deeper than this with that object's validation error. A client parses a relay response or SSE `data` as a whole within `MAX_JSON_DEPTH + 3` (the deepest wrapper: an inbox page holds its packets at depth 3), then checks each contained packet, PublishedAgent and CardBundle within `MAX_JSON_DEPTH`; either failure is `relay_protocol_error` for that whole response. ### Canonical JSON `canonicalStateBytes(value)` is the single canonical serialization used for hashing, signing and size limits. It is UTF-8 with no whitespace: - `null`, `true`, `false` as literals. - A number as the ECMAScript `Number::toString` of its IEEE 754 binary64 value (RFC 8785 §3.2.2.3); `-0` is `0`. Every protocol integer is therefore `decimal(n)`. - A string as `"`, its characters, `"`, escaping exactly: `"` → `\"`, `\` → `\\`, U+0008 → `\b`, U+0009 → `\t`, U+000A → `\n`, U+000C → `\f`, U+000D → `\r`, and every other code point below U+0020 as `\u00xx` with lowercase hex. Everything else, including `/`, U+007F and non-ASCII, is emitted as raw UTF-8. A string containing an unpaired surrogate is rejected. - An array as `[`, its elements in order separated by `,`, `]`. - An object as `{`, its members `"key":value` separated by `,`, `}`, with members sorted by key in ascending **Unicode code point order**: keys are compared as sequences of code points, the first differing code point decides, and a proper prefix sorts first. This equals byte order of the UTF-8 encodings. (It differs from RFC 8785, which orders by UTF-16 code units, only for keys mixing supplementary-plane characters with U+E000–U+FFFF.) The **claims** of a signed object are the complete object without its outer `signature` member. Nested signatures are part of the claims. ## Signatures ``` encodePayload(f1, …, fn) = len(f1)[4, big-endian] ‖ f1 ‖ … ‖ len(fn)[4, big-endian] ‖ fn M = encodePayload("ace.v1", aceId, decimal(timestamp), seqOrEmpty, payload) sig = ML-DSA.Sign(sk, M, ctx = UTF-8(action)) ``` String fields are UTF-8; byte fields are used as is; `encodePayload()` is the empty byte string. `seqOrEmpty` is the empty string for a KEL event signature and `decimal(seq)` of the signing delegation for every other signature. `aceId`, `timestamp` and `payload` per action are listed in [§ Signing actions](#signing-actions). Signing is FIPS 204 ML-DSA, hedged, with no pre-hash; verification is `ML-DSA.Verify(pk, M, ctx, sig)`. The action is the ML-DSA context string, so a signature made under one action never verifies under another. Objects whose claims have the same shape (a delivery permit and a delegation credential) are distinguished only by the ML-DSA context; an implementation that ignores the context would make them interchangeable. A verifier rejects a signature of any length other than the scheme's and MUST NOT accept HashML-DSA. Verifiers never compare signature bytes. | Scheme | Algorithm | Use | Public key | Private key | Signature | |---|---|---|---|---|---| | `ml-dsa-65` | ML-DSA-65 (FIPS 204, NIST category 3) | root, signing key | 1952 bytes | 32-byte seed (FIPS 204 § 3.6.3) | 3309 bytes | `ml-dsa-65` is the only scheme; key and signature objects carry `scheme` so that a revision can add one ([06](/docs/delivery#security-considerations)). ### Delegated signatures Every signature except a KEL event signature is a **delegated signature**: made by the delegated signing key, with `seq` the `seq` of its `dlg`. Its signature object is the closed `{scheme, seq, value}`: `scheme` is `ml-dsa-65`, `seq` a wire integer, `value` canonical Base64 of 3309 bytes (4412 characters). Verification follows [01](/docs/identity#operational-signatures). KEL event signatures are `{scheme, value}` with `seqOrEmpty` empty ([01](/docs/identity#key-event-log)). ### Signing actions The complete list. `aceId` and `timestamp` are the inputs of `M`. | Action | Object | `aceId`, `timestamp` | Payload | Defined in | |---|---|---|---|---| | `kel` | KEL event (`seqOrEmpty` empty) | the identity, event `ts` | `eventBytes` | [01](/docs/identity#key-event-log) | | `packet` | Packet | `from`, `timestamp` | `encodePayload(to, messageId, prekeyId, kemCiphertextBytes, payloadBytes)` | [03](/docs/packet#signature) | | `agent-descriptor` | Descriptor | `aceId`, `issuedAt` | `canonicalStateBytes(claims)` | [04](/docs/descriptor) | | `delivery-permit` | Delivery permit | `issuer`, `issuedAt` | `canonicalStateBytes(claims)` | [05](/docs/admission#delivery-permits) | | `card-record` | Card record | `aceId`, `issuedAt` | `canonicalStateBytes(claims)` | [04](/docs/descriptor#card-record) | | `register-request` | `POST /v1/register` body | `aceId`, `timestamp` | `encodePayload(lastHashHex, audience)` | [07](/docs/relay#registration) | | `listen` | `GET /v1/listen` | auth headers | `encodePayload(sinceOrDash)` | [07](/docs/relay#authentication) | | `inbox` | `GET /v1/inbox` | auth headers | `encodePayload(sinceOrDash, decimal(limit))` | [07](/docs/relay#inbox) | | `inbox-ack` | `POST /v1/inbox/ack` | auth headers | `encodePayload(through)` | [07](/docs/relay#inbox) | | `block` | `POST /v1/blocks` | auth headers | `encodePayload(peer, blocked ? "block" : "unblock", decimal(revision))` | [07](/docs/relay#blocks) | | `unregister` | `POST /v1/unregister` | auth headers | empty | [07](/docs/relay#registration) | | `wake` | `PUT` / `DELETE /v1/wake` | auth headers | `encodePayload(gateway, token)`; empty for `DELETE` | [07](/docs/relay#wake-hints) | A profile MAY define signed objects of its own; their action is the profile ID (which contains `:`, so it never equals a core action). Header-authenticated actions sign `encodePayload(audience, nonce, actionPayload)`, where `actionPayload` is the payload above ([07](/docs/relay#authentication)); they are never signed directly. ## Constants Normative values used by every implementation and relay. | Name | Value | Meaning | |---|---|---| | `TIMESTAMP_WINDOW_SECONDS` | 300 | Future bound for every signed timestamp; freshness window for relay requests | | `OFFLINE_WINDOW_SECONDS` | 604800 | Past bound for packets; relay queue retention; Outbox retry horizon; lifetime of an outgoing `pending` entry; prekey retention; minimum relay retention of an unregistered identity's KEL | | `MAX_PLAINTEXT_BYTES` | 1179648 | Canonical private content (1 MiB + 128 KiB) | | `MAX_PACKET_BYTES` | 2097152 | Canonical JSON of a packet | | `MAX_SEND_BODY_BYTES` | 2162688 | Body of `POST /v1/send` (`MAX_PACKET_BYTES + 65536`) | | `MAX_PUBLISHED_AGENT_BYTES` | 1048576 | Canonical JSON of a PublishedAgent; also bounds a registration request and a CardBundle | | `MAX_CARD_RECORD_BYTES` | 16384 | Canonical JSON of a card record | | `MAX_JSON_DEPTH` | 32 | Depth of each protocol object ([§ JSON values](#json-values)) | | `MAX_TYPE_BYTES` | 256 | Namespaced identifier | | `MAX_THREAD_ID_LENGTH` | 256 | Thread ID code points | | `MAX_KEL_EVENTS` | 256 | Events per KEL | | `MAX_SIGNING_KEY_LIFETIME` | 31622400 | `notAfter - ts` of a `dlg` signing key | | `MAX_DESCRIPTOR_LIFETIME` | 604800 | `expiresAt - issuedAt` of a descriptor | | `MAX_PERMIT_LIFETIME` | 2592000 | `expiresAt - issuedAt` of a delivery permit | | `PERMIT_RENEWAL_SECONDS` | 1296000 | Remaining permit lifetime below which a receipt carries a renewal | | `MAX_RETAINED_PREKEYS` | 16 | Superseded prekey seeds an endpoint retains | | `MAX_EXT_BYTES` | 32768 | Canonical JSON of a descriptor `ext` | | `MAX_PAY_ACCOUNTS` | 8 | Entries of a card record `pay` | | `MAX_NOTE_BYTES` | 512 | UTF-8 bytes of a contact `note` | | `MAX_INBOX_PAGE` | 100 | Maximum and default `limit` of inbox and directory pages | | `MAX_PAGE_BYTES` | 8388608 | Serialized inbox or directory page | | `MAX_MANIFEST_BYTES` | 65536 | Canonical JSON of a profile manifest | | `MAX_PENDING_CONTACT_REQUESTS` | 16 | Held incoming contact requests ([05](/docs/admission#flow)) | | `CONTACT_REQUEST_TTL_SECONDS` | 86400 | Lifetime of a held incoming contact request and of a relay's stranger entries | | `MAX_STRANGER_KEL_EVENTS` | 32 | Events of the KEL in a stranger's contact `source` | | `STRANGER_ALLOWANCE` | 16 | Queued stranger entries per recipient at a relay ([07](/docs/relay#send)) | | `STRANGER_NETWORK_ALLOWANCE` | 2 | Queued stranger entries per client network per recipient at a relay | | `CLIENT_NETWORK_PREFIX_IPV4` | 24 | Prefix length of an IPv4 client network ([07](/docs/relay#client-networks)) | | `CLIENT_NETWORK_PREFIX_IPV6` | 56 | Prefix length of an IPv6 client network | | `HANDLE_CLAIMS_PER_NETWORK_PER_DAY` | 8 | New handles a relay lets one client network claim per 86400 seconds ([07](/docs/relay#descriptors-and-cards)) | | `MIN_BLOCK_CAP` | 256 | Least number of tracked block peers per identity a relay may cap at | | `PIN_REFRESH_INTERVAL_SECONDS` | 30 | Minimum interval between pin refreshes of one sender in the receive pipeline | | `MAX_FETCH_ATTEMPTS` | 3 | Fetches of one inbox entry without a final disposition before it is quarantined | --- Source: https://aceprotocol.org/docs/packet # 03 — Packet The packet is the only object agents exchange. It is encrypted to a prekey of the recipient — the published prekey of its descriptor ([04](/docs/descriptor#prekey)) or a peer prekey it offered the sender ([04](/docs/descriptor#peer-prekeys)) — and signed by the sender's delegated signing key. ```json { "ace": "1", "messageId": "550e8400-e29b-41d4-a716-446655440000", "from": "ace:sha256:…", "to": "ace:sha256:…", "timestamp": 1760140800, "encryption": { "prekey": "<64 hex>", "kemCiphertext": "", "payload": "" }, "signature": { "scheme": "ml-dsa-65", "seq": 1, "value": "" } } ``` ## Decoding A packet is accepted only if all of the following hold. `ace` is checked first; every other failure is `invalid_packet`. | Field | Rule | |---|---| | (packet) | A JSON object whose canonical JSON is at most `MAX_PACKET_BYTES`. A transport rejects a larger serialized packet before parsing | | `ace` | A string; any value other than `"1"` is `unsupported_version` | | `messageId` | Message ID ([02](/docs/encoding#identifiers)); chosen by the sender, unique per sender, and kept across retries | | `from`, `to` | ACE IDs; `from != to` | | `timestamp` | Wire integer: the signer's `t` ([01](/docs/identity#operational-signatures)) at this signing attempt | | `encryption.prekey` | Hex digest: the recipient's prekey ID | | `encryption.kemCiphertext` | Canonical Base64 of exactly 1120 bytes | | `encryption.payload` | Canonical Base64; decoded length in `[28, MAX_PLAINTEXT_BYTES + 28]` | | `signature` | Delegated signature object ([02](/docs/encoding#delegated-signatures)) | All three objects are closed: an unknown member at any level is `invalid_packet`. There is no conversation identifier on the wire. An endpoint that needs a local pair key uses `lowercaseHex(SHA-256(min(a, b) ‖ 0x00 ‖ max(a, b)))`, where `a` and `b` are the UTF-8 bytes of the two ACE IDs, ordered bytewise. ## Encryption `plaintext` is the canonical JSON of the private content ([§ Private content](#private-content)). ``` header = encodePayload("ace.v1.packet", from, to, messageId, decimal(timestamp), prekeyId, kemCiphertextBytes) (ss, kemCiphertextBytes) = XWing.Encapsulate(prekey.publicKey) key = HKDF-SHA256(ikm = ss, salt = "" (empty), info = header, L = 32) payload = nonce[12] ‖ AES-256-GCM(key, nonce, plaintext, aad = header) // ciphertext ‖ tag[16] ``` The nonce is 12 random bytes. Every packet encapsulates a fresh secret, so every key is independent. `ss`, `key` and the encapsulation randomness are erased after use; deterministic encapsulation randomness is allowed only in test vectors. The receiver looks up the prekey seed by `encryption.prekey` among its published prekeys and the peer prekeys it offered to `from` ([04](/docs/descriptor#peer-prekeys); `unknown_prekey` if it holds none), decapsulates, derives `key` from the received header fields and opens the AEAD. Decapsulation and AEAD failures are both `decryption_failed`. `header` binds `from`, `to`, `messageId`, `timestamp` and the prekey. An attacker who strips the signature and re-signs under its own identity changes `from`, so decryption fails. ### X-Wing Normative reference: `draft-connolly-cfrg-xwing-kem-11` (this exact revision). | Item | Value | |---|---| | Private key | 32-byte seed; `expanded = SHAKE256(seed, 96)`, `expanded[0:64]` is the ML-KEM-768 key-generation seed `(d, z)`, `expanded[64:96]` the X25519 secret | | Public key | `pk_M[1184] ‖ pk_X[32]`, 1216 bytes | | Ciphertext | `ct_M[1088] ‖ ct_X[32]`, 1120 bytes | | Shared secret | `SHA3-256(ss_M ‖ ss_X ‖ ct_X ‖ pk_X ‖ 0x5c2e2f2f5e5c)`, 32 bytes | Implementations MUST NOT add separate X25519 small-order or all-zero checks beyond what the draft specifies. Where the X25519 primitive itself refuses an all-zero output (common libraries do, with no way around it), the operation fails: `Encapsulate` as `invalid_argument` at the sender, `Decapsulate` as `decryption_failed` at the receiver ([06](/docs/delivery#receive-pipeline) step 6). A low-order value comes only from the party that supplied the key or the ciphertext, so either outcome affects only packets to or from that party. An implementation MUST reproduce the draft's test vectors. ## Signature Action `packet`, delegated signature ([02](/docs/encoding#delegated-signatures)) with `aceId = from`, `timestamp` and payload ``` encodePayload(to, messageId, prekeyId, kemCiphertextBytes, payloadBytes) ``` `prekeyId` is the 64-character hex string; the other byte fields are decoded bytes. The signature commits to the ciphertext, so the receiver verifies it before decrypting ([06](/docs/delivery#receive-pipeline)). Swapping `kemCiphertext` or the prekey breaks the signature. ## Private content The plaintext is the canonical JSON ([02](/docs/encoding#canonical-json)) of exactly: ``` { "type": "ace:text:1", "schemaDigest": "<64 hex>", "threadId"?: "", "body": { "message": "hello" }, "admission"?: { "proof": , "permit": , "source": }, "prekey": "" } ``` - `type` is a namespaced identifier; `schemaDigest` is the **message digest** of that type in an installed manifest ([08](/docs/profiles#digests)); `body` is a JSON object; `threadId`, when present, is a thread ID. A JSON `null` `threadId` is rejected; absence is the only way to omit it. - `admission` and `prekey` are protocol members that the receiver removes before the body reaches the application. `admission`, when present, is the closed object `{proof, permit, source}` of three JSON objects, the delivery permits and PublishedAgent of an Outbox retry ([05](/docs/admission#admission-recovery)); on a receipt or contact packet (`ace:receipt:1`, `ace:contact:1`) it is `invalid_body`. `prekey` is required: the canonical Base64 of the 1216-byte X-Wing public key the sender offers `to` as a peer prekey ([04](/docs/descriptor#peer-prekeys)). No other members are allowed. - The plaintext follows the JSON value rules ([02](/docs/encoding#json-values)) and is at most `MAX_PLAINTEXT_BYTES`. A sender rejects larger content before encrypting. - Any parse or shape failure of the decrypted plaintext is `invalid_body`. Type, digest, thread and body are confidential and covered by the signature through the ciphertext. Relays route by `to` only. --- Source: https://aceprotocol.org/docs/descriptor # 04 — Descriptor An agent publishes a **PublishedAgent**: the closed object `{kel, descriptor}`, whose canonical JSON is at most `MAX_PUBLISHED_AGENT_BYTES`. `kel` is the agent's complete KEL ([01](/docs/identity)). The descriptor is self-asserted, signed and short-lived: it carries what changes (routes, prekey, profiles). Durable identity facts live in the card record ([§ Card record](#card-record)). ## Fields The descriptor is a closed object: | Field | Rule | |---|---| | `aceId` | The ACE ID of `kel` | | `issuedAt`, `expiresAt` | Wire integers: `issuedAt <= now + TIMESTAMP_WINDOW_SECONDS`, `issuedAt < expiresAt`, `now < expiresAt`, `expiresAt - issuedAt <= MAX_DESCRIPTOR_LIFETIME` | | `relays` | 1–4 distinct canonical relay URLs ([02](/docs/encoding#relay-urls)) at which the agent is registered and reads its inbox | | `prekey` | `{id, publicKey}` ([§ Prekey](#prekey)) | | `profiles` | At most 32 `{id, digest}` with distinct `id`s: each enabled profile and its profile digest ([08](/docs/profiles#digests)). `ace:core:1` is always installed and MUST NOT be listed | | `ext` | Optional; [§ Extensions](#extensions) | | `signature` | Delegated signature, action `agent-descriptor`, with `aceId`, `timestamp = issuedAt` and payload `canonicalStateBytes(claims)` ([02](/docs/encoding#signing-actions)) | ## Verification A verifier checks a PublishedAgent at one clock reading `now`, in this order; the first failure wins: 1. Shape: the closed objects and field rules above, including the `ext` shape (`invalid_authorization`). 2. `kel` verifies and is usable at `now` ([01](/docs/identity#verifier-rules); `invalid_kel` / `invalid_signature`); `aceId` equals its ACE ID (`invalid_authorization`). 3. The signature verifies as an operational signature of that KEL ([01](/docs/identity#operational-signatures); `stale_peer_binding` / `invalid_authorization`). Any invalid field invalidates the whole descriptor. Unknown members are invalid. Lookup results are always complete signed PublishedAgents, and directory entries complete signed CardBundles ([§ Card record](#card-record)); a client MUST verify each one and MUST NOT merge unsigned metadata into it. Peers pin descriptors under [01](/docs/identity#pinning). A verified descriptor proves who asserted its contents, not that they are true. A signer sets the `issuedAt` of a descriptor or card record to `max(now, ts of its last KEL event, issuedAt of its previous one + 1)`. ## Caching A cached descriptor is bound to the KEL states it was verified under. A client uses it, to encrypt or to report its contents, only while it is unexpired and its `kel` ends at the last event of the subject's current pin. Otherwise it is stale: the client refreshes it and, if the refresh fails, reports that failure; it MUST NOT fall back to the stale descriptor. A delivery permit is presented only to relays named by a cached descriptor whose `kel` still ends at the subject's current pin (an expired one is fine), and only a permit that verifies under the issuer's current pin: a permit signed by a superseded key is never presented. A descriptor signed under a superseded key may name attacker-chosen relays, so it is refreshed only with the anonymous public lookup at its relays and the client's home relay. A newly verified descriptor replaces the cached one regardless of `issuedAt` only when the subject's KEL advanced (the cached one's `kel` no longer ends at the current pin). Under the same KEL a newer `issuedAt` replaces it, an equal `issuedAt` requires identical claims, and an older one never replaces it, even when the cached one is expired (no rollback). When an older candidate is refused, the client returns the cached descriptor only if it is usable, else it fails with `stale_peer_binding`. ## Prekey `prekey.publicKey` is the canonical Base64 of a 1216-byte X-Wing public key ([03](/docs/packet#x-wing)); `prekey.id` is the lowercase hex SHA-256 of those 1216 bytes. A verifier recomputes the ID. - An agent MAY rotate its prekey whenever it publishes a descriptor, and SHOULD NOT rotate more than `MAX_RETAINED_PREKEYS` times per `OFFLINE_WINDOW_SECONDS`. - When a prekey is superseded, the agent keeps its seed for `OFFLINE_WINDOW_SECONDS` after the moment of supersession, then deletes it. It keeps at most `MAX_RETAINED_PREKEYS` superseded seeds, deleting the oldest first. - A packet referencing a prekey the agent does not hold is rejected with `unknown_prekey` ([06](/docs/delivery#receive-pipeline)). - Prekey seeds are secrets protected like identity seeds. - A superseded prekey whose retention has ended MUST NOT open a packet, even when its seed has not been deleted yet. Implementations delete expired seeds whenever they open a packet or rotate, not only on publication. ## Peer prekeys Besides its published prekey, an endpoint offers each peer a **peer prekey**: an X-Wing public key carried as `prekey` in every packet it sends that peer ([03](/docs/packet#private-content)), to which the peer encrypts its next first-attempt application message ([06](/docs/delivery#outbox-sender)). The endpoint that generated the key is its **issuer**; the peer that received it is its **holder**. Its ID is the lowercase hex SHA-256 of the 1216 bytes, as for a published prekey. **Issuer.** Per peer, the issuer keeps at most two seeds: the *offered* key and the *in-use* key. - The first packet to a peer generates its offered key; every later packet to that peer carries the same offered key until it is used or deleted. - When a packet from the peer opens under the offered key, the issuer, before acknowledging that packet at its relay: deletes the in-use seed, makes the offered key the in-use key, and generates a new offered key for its next packet. A packet opening under the in-use key changes nothing. - A seed, offered or in use, is deleted `OFFLINE_WINDOW_SECONDS` after the timestamp of the last packet that carried it as the offered key. - A peer prekey opens only packets whose `from` is the peer it was offered to; any other packet referencing it is `unknown_prekey`. - Seeds are protected like published prekey seeds and survive restarts. They are lost with the endpoint's state; the Outbox recovers ([06](/docs/delivery#outbox-sender)). **Holder.** The holder keeps at most one key per peer. - A packet that opened (step 6) and whose sender is pinned, at step 3 or by the recovery path ([05](/docs/admission#admission-recovery)), records its `prekey` as the held key for `from` when the packet's `timestamp` is greater than the held key's, bound to the pin state (the digest of the last pinned KEL event) it was authenticated under; duplicates at step 9 record too. A contact packet, and any packet whose sender has no pin when it is processed, records nothing. - The held key is used only while the peer's pin is the one it is bound to and less than `OFFLINE_WINDOW_SECONDS` have passed since the timestamp of the packet that carried it. It is dropped when the peer is blocked, when a receipt `rejected:unknown_prekey` names it, and when encapsulating to it fails ([03](/docs/packet#x-wing)); that attempt then encrypts to the published prekey. ## Card record An agent states its durable identity facts in a **card record** and publishes it at its home relay, one of its descriptor's `relays`, as the closed **CardBundle** `{kel, card}`, whose canonical JSON is at most `MAX_PUBLISHED_AGENT_BYTES`. `kel` is the agent's complete KEL; `card` is the card record, a closed object whose canonical JSON is at most `MAX_CARD_RECORD_BYTES`: | Field | Rule | |---|---| | `aceId` | The ACE ID of `kel` | | `issuedAt` | Wire integer, `issuedAt <= now + TIMESTAMP_WINDOW_SECONDS` | | `home` | Canonical relay URL ([02](/docs/encoding#relay-urls)) of the relay that holds the record; `handle` belongs to its namespace | | `handle` | Optional handle ([02](/docs/encoding#identifiers)); only with a `home` whose URL has no path, because a handle resolves at `https://host` | | `card` | [§ Card](#card) | | `pay` | Optional, 1–`MAX_PAY_ACCOUNTS` distinct CAIP-10 accounts ([02](/docs/encoding#identifiers)) at which the agent asks to be paid | | `erc8004` | Optional, 1–4 distinct `{agentRegistry, agentId}`: `agentRegistry` a CAIP-10 account; `agentId`, when the CAIP-2 namespace of `agentRegistry` is `eip155`, a decimal string matching `^(0\|[1-9][0-9]{0,77})$`, otherwise a string matching `^[A-Za-z0-9]{1,128}$` | | `contact` | `open` or `closed`: whether the agent processes contact requests from strangers ([05](/docs/admission#flow)) | | `listed` | Boolean: whether the agent appears in its home relay's directory ([07](/docs/relay#descriptors-and-cards)) | | `signature` | Delegated signature, action `card-record`, with `aceId`, `timestamp = issuedAt` and payload `canonicalStateBytes(claims)` ([02](/docs/encoding#signing-actions)) | A verifier checks a CardBundle at one clock reading `now`, in this order; the first failure wins: 1. Shape: the closed objects and field rules above, including both size bounds (`invalid_authorization`). 2. `kel` verifies and is usable at `now` ([01](/docs/identity#verifier-rules); `invalid_kel` / `invalid_signature`); `aceId` equals its ACE ID (`invalid_authorization`). 3. The signature verifies as an operational signature of that KEL ([§ Verification](#verification) step 3; `stale_peer_binding` / `invalid_authorization`). A card record does not expire: it stands until a newer `issuedAt` replaces it or the identity unregisters. A record signed under a superseded delegation fails step 3, so an agent re-signs its record after every `dlg`. A verified record proves who asserted it, not that it is true: a `pay` account is claimed, not proven to be controlled by the agent. An agent without a card record is treated as `contact: closed` and unlisted. Like `pay`, an `erc8004` entry is claimed, not proven. The claim holds only when the registry's registration file for that agent (its `tokenURI`) is a JSON object whose `services` array contains `{"name": "ACE", "endpoint": ""}`; reading the chain is the host's task and the core contains no chain client. A client that has verified a card record for an identity MUST NOT accept one with an older `issuedAt`, nor one with an equal `issuedAt` and different claims (`stale_descriptor`). It keeps the highest `issuedAt` and the claims digest at it for each identity, durably, and checks them before the handle rule below. A card record is revoked only by a new `dlg`: the signing key can sign records, `pay` and `erc8004` included, until a `dlg` replaces it ([06](/docs/delivery#security-considerations)). A client MUST NOT rely on `pay` or `erc8004` of a record unless it has pinned the identity's KEL from a live descriptor ([§ Verification](#verification)) and the record verifies under that pin; a client that looks a card up for that purpose therefore looks the descriptor up first and fails with the descriptor's error. Every registered agent's unexpired descriptor can be looked up by ACE ID ([07](/docs/relay#descriptors-and-cards)); `contact` and `listed` decide only who may request contact and whether the agent is listed. A client looking up a card record by ACE ID asks the relays of the identity's descriptor in listed order until one serves it. **Handles.** A handle is written `@name@host`, where `host` is the host (with its port, if any) of `home`, and the record member is the slug `name`. A handle is set once: an identity claims it with the first card record that carries it, and the relay of `home` binds it to that identity permanently, also after unregister; it is never reassigned or renamed. For as long as `home` names the same relay, every card record the identity publishes MUST carry the same `handle`. A record naming another `home` claims nothing at the earlier relay, whose binding stays; the handle there then resolves as `unknown_peer`. A client that has pinned a handle for an ACE ID at a `home` treats a CardBundle of that identity naming the same `home` with a different or missing `handle` as `invalid_authorization`. A client reading a handle address typed by a person MAY accept it without the leading `@` and in any ASCII case, lowercasing it before checking the grammar; records and requests carry only the canonical form. A client resolving `@name@host` queries `GET /v1/handles/{name}` at the relay of `host`, verifies the returned CardBundle and MUST check that its `home` is the relay it queried and its `handle` is `name`; otherwise the result is `unknown_peer`. The binding of `@name@host` to an ACE ID is trusted to the relay of `host`. Bindings are permanent, so a relay that resolves a handle to a different ACE ID than before has misbehaved detectably: a client MUST pin `(home, handle) → aceId`, durably, on its first resolution and treat a resolution to a different ACE ID as `invalid_authorization`. Contacts are saved by ACE ID; a handle only finds one. ## Card The `card` member of a card record is a closed object; every member is optional: | Member | Rule | |---|---| | `description` | At most 256 characters, no U+0000–U+001F or U+007F | | `image` | ACE HTTPS URL ([02](/docs/encoding#https-urls)) | | `tags` | At most 10 distinct strings matching `^[a-z0-9-]{1,32}$` | The card carries what discovery needs and nothing that names the agent: a display name changes and is application data; the permanent handle is the protocol's human-readable identifier. Card contents are untrusted self-assertions. The tag `hosted` is reserved: it declares that a service holds the agent's keys. A hosted runtime MUST include it and MUST NOT allow its removal. No other tag has protocol meaning. ## Extensions `ext` is an object whose keys are profile IDs listed in `profiles`, whose values are JSON objects, and whose canonical JSON is at most `MAX_EXT_BYTES`. A profile defines its own `ext` value: an official profile's chapter states the schema, which every verifier enforces ([profiles/commerce.md](/docs/commerce#descriptor-extension), [profiles/delegation.md](/docs/delegation-profile#descriptor-extension)); a key for an official profile that defines no `ext` is invalid. Values for third-party profiles are opaque objects. No `ext` value confers authority. --- Source: https://aceprotocol.org/docs/admission # 05 — Admission Admission decides who may deliver packets to an agent. It is communication permission only: admitting a peer never grants shell, file, payment or execution rights, and never makes remote content trusted. Each endpoint keeps a durable **admission table** keyed by peer ACE ID. A peer is in one of these states: | State | Meaning | |---|---| | `pending` | The local agent sent the peer a contact request and is waiting for a reply | | `admitted` | Contact was accepted in either direction; the local agent issued the peer a permit | | `blocked` | The local agent blocked the peer | A peer absent from the table is a **stranger**. The table is a cache. The decision it records is the permit the endpoint issued: its subject holds it and the endpoint's KEL verifies it. An endpoint that loses the table but keeps its identity (a reinstall, another device of the same identity) recovers each admitted peer from the permit that peer presents ([§ Admission recovery](#admission-recovery)). ## Delivery permits A delivery permit is the closed object: ``` { issuer, subject, issuedAt, expiresAt, signature } ``` - `issuer` is the receiving agent and `subject` the distinct sending agent. - `issuedAt <= now + TIMESTAMP_WINDOW_SECONDS`, `issuedAt < expiresAt`, `expiresAt - issuedAt <= MAX_PERMIT_LIFETIME`; a permit is valid while `now < expiresAt`. - Signed by the issuer with action `delivery-permit`, `aceId = issuer`, `timestamp = issuedAt` and payload `canonicalStateBytes(claims)`, and verified as an operational signature of the issuer's current KEL, which is therefore usable at `now` ([01](/docs/identity#operational-signatures)). A permit lets the subject deliver packets to the issuer through the issuer's relays ([07](/docs/relay#send)), whatever the issuer's `contact` setting, and admits it at the issuer ([§ Admission recovery](#admission-recovery)). It is a bearer credential for that use only and grants nothing else. A signing-key change at the issuer invalidates its earlier permits, so the issuer sends fresh ones: after a new `dlg`, at its next upkeep, it re-sends `kind: request` for the recorded profile (a profile both list when the row records none), carrying a fresh permit, to every peer it holds as `admitted` and has not yet issued a permit under the current delegation. The peer accepts it without a local decision (§ Receiver admission) and answers `accepted`, whose permit replaces the one held ([§ Flow](#flow) step 4). Until then the admitted peers of a `closed` issuer cannot reach it. **Upkeep.** An endpoint runs upkeep on start and at least every 3600 seconds (the longest Outbox retry delay, [06](/docs/delivery#outbox-sender)): it republishes its descriptor before it expires and its card record after a new `dlg` ([04](/docs/descriptor#card-record)), sends the permit refreshes above, re-registers blocks its relays have not confirmed ([§ Blocks](#blocks)) and runs due Outbox retries. A step that fails is retried at the next upkeep. **Renewal.** When an endpoint sends a receipt to a peer and the newest permit it issued to that peer has less than `PERMIT_RENEWAL_SECONDS` left (or is no longer valid under the issuer's current KEL), it includes a fresh permit in the receipt ([06](/docs/delivery#receipts)). ## Contact requests A contact request is a packet ([03](/docs/packet)) whose private content has type `ace:contact:1` from the always-installed core profile ([08](/docs/profiles#official-manifests)). Its body: | Member | Rule | |---|---| | `kind` | `request`, `accepted` or `rejected` | | `profile` | Profile ID the contact is about; both endpoints MUST list it with the same profile digest | | `note` | String of at most `MAX_NOTE_BYTES` UTF-8 bytes; encrypted like any body | | `source` | The sender's PublishedAgent; MUST verify ([04](/docs/descriptor#verification)) and name `from` | | `permit` | Required when `kind` is `request` or `accepted`, absent when `rejected`: a reply permit issued by the sender (`issuer = from`) to the recipient (`subject = to`) | A receiver accepts a packet from a sender whose KEL it has not pinned only as a contact candidate or a recovery candidate ([06](/docs/delivery#receive-pipeline)), verifying the packet signature under `source.kel`. Contact packets are neither retried nor receipted ([06](/docs/delivery#outbox-sender)). Contact content MUST NOT carry credentials or other secrets beyond the permit. ### Flow 1. **Connect.** The requester obtains and verifies the target's PublishedAgent (by ACE ID lookup, after finding the ACE ID in the directory or through a handle, or one shared out of band), pins it, records the target as `pending` with the requested profile, and sends `kind: request`. Unless the requester already holds a permit from the target, it SHOULD first obtain and verify the target's card record ([04](/docs/descriptor#card-record)) and send only when its `contact` is `open`, reporting a target without a card record, or whose record is `closed`, as `not_permitted`; the relay ([07](/docs/relay#send) step 5) and the receiver (step 2) enforce it regardless. A request sent without a permit goes to the `home` of the target's card record. The requester's own `contact` setting does not matter: a `closed` agent connects to an `open` one. Connecting to a peer held as `blocked` fails with `not_permitted` and changes nothing ([§ Blocks](#blocks)). 2. **Decide.** A receiver processes a stranger's request only if its own card record is `contact: open`; without a card record it is `closed`. It accepts only by an explicit local accept action or because the profile matches a locally configured `acceptProfiles` rule with an equal profile digest. A request never changes admission state by itself; it is held as a pending request until a decision. The receiver holds at most `MAX_PENDING_CONTACT_REQUESTS` pending requests and at most one per sender: a newer request from the same sender replaces the held one, a held request expires `CONTACT_REQUEST_TTL_SECONDS` after it was received, and a new request arriving when the store is full evicts the oldest held request (by time received). Expired requests are never listed or accepted. 3. **Accept.** The receiver pins `source`, stores the requester's permit, records the requester as `admitted`, and replies `kind: accepted` with its own permit, using the requester's permit to send. To decline it MAY reply `kind: rejected`. 4. **Complete.** The requester accepts a reply only from a peer it holds as `pending` for the same profile. On `accepted` it stores the permit and records the peer as `admitted`; on `rejected` it removes the `pending` entry. An `accepted` from a peer already held as `admitted` (the answer to a permit refresh) only replaces the held permit; any other reply is ignored. A `pending` entry expires `OFFLINE_WINDOW_SECONDS` after its request was sent: it is deleted, the peer is a stranger again, and a later `accepted` is ignored. Connecting again repairs a late `accepted` ignored this way: the target accepts an admitted peer's request without a decision. ### Invitations Pairing can also start out of band, for example when an agent is `closed` to strangers. An **invitation** is the closed object `{agent, permit}`: the inviter's PublishedAgent and a delivery permit issued by the inviter to one peer. It travels only over a private channel; the permit is a bearer credential. 1. **Create.** The inviter obtains and verifies the peer's PublishedAgent (shared out of band, or looked up), pins it, records the peer as `admitted` (an explicit local decision, which clears a block) and issues the permit to that peer. 2. **Import.** The invitee verifies `agent` ([04](/docs/descriptor#verification)) and the permit with `issuer = agent.aceId` and `subject` = the invitee (`invalid_authorization` otherwise), pins the agent and holds the permit. Importing MUST NOT change the invitee's admission table: the inviter stays a stranger to it. 3. **Connect.** The invitee connects with the held permit (step 1 above). The inviter holds the invitee as `admitted`, so the request passes receiver admission even when the inviter is `closed`, and the inviter accepts it; on `accepted` the invitee admits the inviter. Descriptors, card records, notes, directory entries and received messages MUST NOT change local admission configuration such as `acceptProfiles`, and MUST NOT install profiles or code. ## Blocks An agent blocks a peer locally and registers the block at each of its relays with `POST /v1/blocks` ([07](/docs/relay#blocks)) as `{peer, blocked, revision}`, where `revision` strictly increases per peer. An endpoint takes `revision = max(previous + 1, now)`, so a decision made after it lost its block records still outranks every earlier one; a relay that answers `stale_revision` holds a later decision. A block the relay has not confirmed is re-sent at upkeep with the same revision. - A relay rejects packets from a blocked sender even with an unexpired permit. - The endpoint independently rejects packets from a blocked peer, and blocking discards the peer's held contact request, so a blocked peer can never be accepted. - Blocks survive restart, and survive the loss of local state at the relays that registered them. Only an explicit local admission action clears a block: `unblock`, or creating an invitation for the peer ([§ Invitations](#invitations)); it registers `blocked: false` with a higher revision. An unblocked peer is a stranger until it presents a permit it still holds, which re-admits it ([§ Admission recovery](#admission-recovery)), or contact is established again. `connect` never clears a block, and contact packets from a blocked peer are dropped before any decision, so no message, prompt or remote reply can lift one. ## Receiver admission Admission is step 2 of the receive pipeline ([06](/docs/delivery#receive-pipeline)): - A `blocked` sender is dropped. - A `pending` or `admitted` sender continues through the pipeline. A request from an `admitted` peer (for example an invitee) is accepted without a further local decision. - A stranger's packet is decrypted without any network lookup or pin write, and continues only as a recovery candidate, when its private content carries an `admission` whose `proof` is valid ([§ Admission recovery](#admission-recovery)), or as a contact candidate, when the local card record is `contact: open` and it is an `ace:contact:1` request. Anything else is dropped. - A stranger's contact `source` is bounded before any of its signatures is verified (a recovery `source` is not: a valid `proof` gates it): its `kel` has at most `MAX_STRANGER_KEL_EVENTS` events; a source outside this bound is dropped. An agent with a longer log reaches an `open` agent through an invitation. Endpoints SHOULD cache successful KEL verifications keyed by the SHA-256 of the log's canonical JSON ([02](/docs/encoding#canonical-json)), re-checking only the time bound on a hit, so a repeated `source` costs no further KEL signature verification. ## Admission recovery An endpoint that lost its admission table, pins and held permits but kept its identity recovers each admitted peer from that peer's next retried message, with no relay involvement: 1. **Sender.** Every Outbox attempt after the first ([06](/docs/delivery#outbox-sender)) to a peer the sender holds as `admitted` carries `admission` in its private content ([03](/docs/packet#private-content)): `proof` is the permit the attempt presents to the relay (`issuer = to`, `subject = from`); `permit` is a reply permit (`issuer = from`, `subject = to`): the newest the sender issued to the peer while it is valid under the sender's current KEL with at least `PERMIT_RENEWAL_SECONDS` left, else a fresh one, which counts as issued once the attempt was sent; and `source` is the sender's current PublishedAgent. `admission` is left out when the content with it would exceed `MAX_PLAINTEXT_BYTES`, or when the sender holds no unexpired PublishedAgent of its own; it never fails an attempt. 2. **Proof.** A receiver that cannot authenticate the sender from its pin (a stranger, or a `pending` or `admitted` peer it has not pinned) checks `proof` locally: `issuer` is the local identity, `subject` is `from`, it verifies as an operational signature of the local current KEL at `now`, and `now < expiresAt`. A packet without a valid `proof` takes the stranger or contact-candidate path. 3. **Restore.** A packet whose type is not installed is dropped. Otherwise a valid `proof` justifies verifying `source`, without any network lookup: it MUST verify as a PublishedAgent naming `from` under the pin rules of [01](/docs/identity#pinning) ([04](/docs/descriptor#verification)); then the packet signature under `source.kel` (step 4) and `admission.permit` with `issuer = from` under it and `subject` = the local identity. Any failure is drop (`invalid_authorization`, `invalid_signature`). Only then does the receiver pin `source`, record the sender as `admitted` (a new row records no profile) with `proof` as the newest permit issued to it, unless the sender is held as `blocked` meanwhile (drop), hold the permit, and continue the pipeline at step 8 ([06](/docs/delivery#receive-pipeline)). 4. **Admitted.** A receiver that authenticates the sender from its pin ignores `proof` and holds `admission.permit` like a receipt renewal when it is valid; an invalid one is ignored. - A signing-key change of the receiver voids its earlier permits ([§ Delivery permits](#delivery-permits)), so recovery cannot cross one: their holders are refused by the relays of a `closed` receiver with `not_permitted` and are strangers to an `open` one, and the peers pair again (`connect`, or an invitation). - Until a peer sends, the recovering endpoint holds no permit from it: it reaches an `open` peer by `connect`, which that peer accepts without a decision because it holds the endpoint as `admitted`, and a `closed` peer once that peer sends to it or through a new invitation. - The lost block records are not recovered ([§ Blocks](#blocks)), and neither is the seen store: a retry of a message the earlier installation already handled is handed to the application again ([06](/docs/delivery#deduplication) requires idempotent effects). --- Source: https://aceprotocol.org/docs/delivery # 06 — Delivery, receipts and security Delivery is asynchronous: the recipient need not be online when a packet is sent. A relay's `{ok:true}` means the packet was queued, never that it was delivered. Delivery is confirmed only by a signed receipt from the recipient. ## Outbox (sender) For each message the sender durably stores `{messageId, to, privateContent, createdAt}` and its attempt state, after running the profile rules' `check`; their `commit` runs when the `delivered` receipt arrives, and a busy thread refuses the message with `thread_busy` ([08](/docs/profiles#sender-state-and-busy-threads)). If the sender holds no descriptor for `to` (an expired or stale one is enough) and cannot resolve one ([07](/docs/relay#descriptors-and-cards)), enqueueing fails immediately with `unknown_peer` and nothing is stored; a transient resolution failure is reported as such. Enqueueing an application message to a peer from which the sender holds no valid delivery permit fails with `not_permitted` and stores nothing. Each attempt: 1. Ensures a valid descriptor for `to`. A cached descriptor is used only while it is bound to the current pins ([04](/docs/descriptor#caching)). An expired or stale one, and on every attempt after the first any cached one (the peer may have lost its prekeys with its state), is refreshed with `GET /v1/descriptor/{aceId}` ([07](/docs/relay#descriptors-and-cards)). If a refresh fails, a retry uses the cached descriptor while it is unexpired and bound to the current pins; otherwise the attempt fails. The sender MUST NOT fall back to a stale descriptor (never encrypting to a prekey published under a superseded signing key). 2. Encrypts the stored private content, with the offered peer prekey for `to` added as `prekey` ([04](/docs/descriptor#peer-prekeys)), and signs it with `timestamp` set to the signer's `t` ([01](/docs/identity#operational-signatures)). The first attempt encrypts to the peer prekey held for `to` while it is usable ([04](/docs/descriptor#peer-prekeys)), else to the published prekey of the descriptor of step 1; every later attempt encrypts to that published prekey, because a peer that lost its state cannot answer a packet it cannot open ([§ Receive pipeline](#receive-pipeline), recovery candidates). `messageId` never changes. Every attempt after the first to a peer the sender holds as `admitted` adds `admission` to the content ([05](/docs/admission#admission-recovery)). 3. Posts `{packet, permit}` to the peer's relays in listed order until one returns `{ok:true}`, with the newest permit issued by the peer that is valid under its current pin. Without one the attempt fails with `not_permitted` and the message waits, on the retry schedule, for a renewal or refresh ([05](/docs/admission#delivery-permits)); an attempt never falls back to the stranger path. Retries continue until a receipt arrives or `OFFLINE_WINDOW_SECONDS` have passed since the first attempt, when the message becomes `expired`. The delay after attempt *n* is `min(60 · 2^(n-1), 3600)` seconds. Relay errors do not end the operation. A host MAY abandon a message locally, which also makes it `expired`. An attempt the host stops before its post completes (cancelled, interrupted) is not an attempt: it changes nothing but its claim. `expired` means only that the sender stopped retrying: the recipient may still have processed the message, so its outcome is unknown until a receipt arrives. A receipt for a pending or `expired` message ends it as `delivered` (running the rules' `commit`) or `rejected` with its code; receipts are the only source of truth about the outcome. The exception is `rejected:unknown_prekey`: it is ignored for an `expired` message, and for a pending one counted only when its `prekey` equals the prekey of the sender's latest attempt (a late or replayed receipt for an earlier attempt is ignored). If that prekey was a peer prekey, the sender drops the held key for `to` (if it is still that key) and keeps retrying; otherwise it refreshes the peer's descriptor and keeps retrying, unless the published prekey it used is still the current one, in which case the rejection is final. A sender applies each `(from, messageId, outcome, prekey)` receipt at most once. A sender MAY delete `delivered` and `rejected` entries at any time, and an `expired` entry once `OFFLINE_WINDOW_SECONDS` (the relay's retention) have passed since it expired. Receipts and contact packets are never stored in the Outbox and never retried. ## Receipts A receipt is a packet whose private content has type `ace:receipt:1` from the core profile ([08](/docs/profiles#official-manifests)). Its body: | Member | Rule | |---|---| | `messageId` | The message the receipt answers | | `outcome` | `delivered` or `rejected` | | `code` | Present exactly when `outcome` is `rejected`; an outcome code ([02](/docs/encoding#identifiers)) | | `prekey` | Present exactly when `code` is `unknown_prekey`: the 64-lowercase-hex prekey ID of the packet the receiver could not open | | `permit` | Optional renewal ([05](/docs/admission#delivery-permits)); when present it MUST be valid with `issuer = from` and `subject = to` of the receipt packet | A receiver sends one receipt for every authenticated packet whose pipeline outcome below is delivered, duplicate or reject, except receipts and contact packets. Sending is a single attempt encrypted to the sender's published prekey (never to a held peer prekey: a receipt produces no receipt, so it must use a key that is always openable), carrying the offered peer prekey, using the sender's descriptor only while it is bound to the current pins ([04](/docs/descriptor#caching)), an expired or stale one refreshed first, and a permit issued by the sender, if held; if no route exists, no receipt is sent. If a receipt is lost, the sender retries, the receiver deduplicates and sends `delivered` again. A receipt matching no Outbox entry (by `from` and `messageId`) is ignored. Contact packets are sent the same way, to the peer's published prekey. ## Receive pipeline A receiver processes every packet in this order; the first failure decides. Each outcome has a disposition: - **drop**: discard silently and acknowledge it at the relay; - **reject(code)**: send `rejected` with `code`, then acknowledge; - **retry**: do not acknowledge; process again on the next fetch; - **hold**: stop consuming the inbox; do not acknowledge. | Step | Check | Failure | |---|---|---| | 1 | Decode the packet ([03](/docs/packet#decoding)); `to` is the local identity | drop (`invalid_packet`, `unsupported_version`, `wrong_recipient`) | | 2 | Admission ([05](/docs/admission#receiver-admission)) | drop, or continue as a recovery or contact candidate (below) | | 3 | The sender's pinned KEL. If `signature.seq` is greater than the pinned delegation's, refresh the pin once from the sender's descriptor, at most once per sender per `PIN_REFRESH_INTERVAL_SECONDS` | suppressed or transient refresh: retry; a permanent refresh failure, or a refreshed pin still behind `signature.seq`: continue as a contact candidate (below) | | 4 | Operational signature against the pin ([01](/docs/identity#operational-signatures)) | drop (`stale_peer_binding`, `invalid_signature`) | | 5 | `now - OFFLINE_WINDOW_SECONDS <= timestamp <= now + TIMESTAMP_WINDOW_SECONDS` | drop (`stale_timestamp`) | | 6 | Decrypt with the referenced prekey ([03](/docs/packet#encryption)), published or offered to `from` | `unknown_prekey`: reject; otherwise drop (`decryption_failed`) | | 7 | Parse private content ([03](/docs/packet#private-content)); `(type, schemaDigest)` is installed | reject (`invalid_body`, `unsupported_schema`) | | 8 | Body matches the installed message schema ([08](/docs/profiles#schema-dialect)) | reject (`invalid_body`) | | 9 | Deduplicate by `(from, messageId)` ([§ Deduplication](#deduplication)) before any profile rule runs, so a retried message is never judged against state it already changed | duplicate: send `delivered`; beyond horizon: drop (`replay_horizon`) | | 10 | Busy-thread check, then profile rules `check` for the type ([08](/docs/profiles#profile-rules)), including the core rules for receipts and contacts; then check capacity and durably commit the seen entry, the delivery record and the rules' `commit` in one transaction. `commit` MUST be idempotent per `(from, messageId)` | reject (`thread_busy` or the rule's code); no capacity: hold; storage failure: retry | | 11 | Send the receipt, then acknowledge at the relay | receipt send failed transiently: retry | Receipt and contact packets never produce receipts: where the table says reject, they are dropped. **Peer prekeys.** After step 6, before the packet is acknowledged and whatever its disposition from step 7 on, the receiver applies the issuer and holder rules of [04](/docs/descriptor#peer-prekeys); the sender counts as pinned when it was authenticated at step 3 or by the recovery path before step 8. `prekey` and `admission` are removed from the content before steps 7–11. An absent or malformed `prekey` records nothing and fails step 7 with `invalid_body`. **Crash recovery.** The durable delivery record is the commit point of step 10. A receiver that finds a delivery record whose hand-over to the application did not complete (on restart, or when the packet arrives again and is deduplicated) rebuilds the rule event from the recorded message (direction receive, `messageId`, `from`, `to`, `timestamp`, `type`, `threadId`), re-runs the rules' `commit` (idempotent), completes the hand-over and only then marks the record complete. A transient failure leaves the record pending for a later retry; it is never discarded. **Contact candidates.** A stranger's packet admitted at step 2 as a contact candidate has no pinned KEL, so steps run in the order 1, 2, 5, 6, 7, 8, then the sender is authenticated: `source` verifies as a PublishedAgent naming `from` ([04](/docs/descriptor#verification)) and the packet signature verifies under `source.kel`; then 9, 10 and the acknowledgement. The type MUST be `ace:contact:1` with `kind: request`. Every failure is drop, and nothing is pinned until the request is accepted ([05](/docs/admission#flow)). A `pending` or `admitted` peer whose KEL is not yet pinned, or whose pin step 3 could not bring up to the packet's delegation (for example a peer after a signing-key change, whose descriptor the receiver has not yet refreshed), takes the same path at step 3 (any `kind`; `source` is verified against the pin rules of [01](/docs/identity#pinning), so it can only extend or recover the pin); its packets other than contacts are dropped. Such a contact's `source` changes the pin only once the packet has passed step 9 and the rule checks of step 10; a packet dropped earlier changes no pin. **Recovery candidates.** A packet whose sender the receiver cannot authenticate from a pin (a stranger at step 2, or a `pending` or `admitted` peer without a pin at step 3) and which is not an `ace:contact:1` packet runs steps 1, 2, 5, 6 and 7, then [05 § Admission recovery](/docs/admission#admission-recovery) steps 2–3, then 8, 9, 10, 11. Every failure before the sender is recorded is drop; from then on the dispositions of the table apply. **Acknowledgement.** The receiver processes inbox entries in stream order and acknowledges through the last entry of the longest prefix of entries that all have a final disposition, stopping at the first that ends in retry or hold ([07](/docs/relay#inbox)). An entry fetched `MAX_FETCH_ATTEMPTS` times without a final disposition is recorded in a local quarantine and dropped. Every fetch that ends in retry counts toward the limit; a hold does not. A packet is therefore never permanently unconsumable. ### Deduplication The seen store holds entries `(timestamp, from, messageId)`, a horizon `H` and per-sender horizons `H[from]`, ordered by `(timestamp, from, messageId)` with strings compared by UTF-8 bytes. For a packet: - **duplicate** if `(from, messageId)` is in the store; - else **beyond horizon** if `timestamp <= H` or `timestamp <= H[from]`; - else **new**. A beyond-horizon packet is dropped and never receipted; `replay_horizon` is a local record code, never a receipt code. The sender's next retry, signed with a fresh timestamp, can still be delivered. Committing a new packet inserts its entry, then applies, with floor `F = now - 2 · OFFLINE_WINDOW_SECONDS`, capacity `C` (at least 100,000) and sender quota `Q = max(1, floor(C / 16))`: 1. **Floor.** While the smallest entry has `timestamp < F`: remove it and raise `H` to its timestamp. 2. **Sender quota.** While the sender holds more than `Q` entries: remove its smallest entry, raise `H[from]` to its timestamp, and remove every entry of that sender at or below `H[from]`. 3. **Capacity.** While there are more than `C` entries: remove the smallest entry `e`, raise `H[e.from]` to its timestamp, and remove every entry of `e.from` at or below it. 4. **Sender horizons.** Delete every `H[x] <= H`. If more than `C` sender horizons remain, sort them by `(H[x], x)` and fold the first `count - floor(C / 2)` into `H`; then remove every entry at or below `H` and delete every `H[x] <= H` again. "Raise X to t" means `X = max(X, t)`. A new store starts with `H = F - 1` and no sender horizons; missing or corrupt state beside existing delivery history is a storage failure, not a new store. Entries and horizons are persisted together. The floor is twice the offline window because a sender re-signs retries with fresh timestamps for up to `OFFLINE_WINDOW_SECONDS` after its first attempt, and each retry may wait that long at the relay. Capacity eviction can still raise an honest sender's horizon; a retry signed after its entry was evicted is then delivered again. Applications MUST therefore apply effects idempotently, keyed by `(from, messageId)`. ACE never promises exactly-once external effects. ## Error codes An SDK reports every failure as one error type carrying a `code`, whose category is fixed. `transient` and `local` failures are retryable; `permanent` ones are not. | Category | Codes | |---|---| | `permanent` | `invalid_argument`, `invalid_packet`, `unsupported_version`, `wrong_recipient`, `invalid_signature`, `invalid_authorization`, `stale_timestamp`, `stale_peer_binding`, `decryption_failed`, `unknown_prekey`, `unsupported_schema`, `invalid_body`, `replay_horizon`, `invalid_kel`, `kel_conflict`, `unknown_peer`, `not_registered`, `not_permitted`, `relay_rejected`, `blocked_address`, `delivery_rejected`, `expired`, `stale_descriptor` | | `transient` | `relay_unavailable`, `relay_protocol_error`, `fetch_failed` | | `local` | `storage_failed`, `identity_unavailable`, `lock_busy`, `receiver_capacity`, `thread_busy` | `delivery_rejected` carries the receipt's `code` as its remote code. Profile rules MAY raise further permanent codes matching the outcome-code grammar ([profiles/commerce.md](/docs/commerce#check-order) defines the commerce codes). Relay responses map to these codes as specified in [07](/docs/relay#client-rules). ## Security considerations **Post-quantum.** Every signature in ACE is ML-DSA (FIPS 204): KEL events, packets, descriptors, card records, permits, registration and relay authentication. Packet confidentiality uses X-Wing (ML-KEM-768 + X25519), which holds unless both components are broken, with HKDF-SHA256 and AES-256-GCM. Control of an identity rests on a hash commitment that includes the scheme, so if ML-DSA weakens, a later revision can add a scheme that owners adopt by committing a next root of that scheme at their next rotation ([02](/docs/encoding#signatures)). **Forward secrecy.** An application message delivered on its first attempt is encrypted to a peer prekey ([04](/docs/descriptor#peer-prekeys)), whose seed the issuer deletes when the holder next uses a newer key or `OFFLINE_WINDOW_SECONDS` after it was last offered, whichever is first; between agents that answer each other promptly the window is one exchange. A retried application message, a receipt and a contact packet are encrypted to the published prekey, whose window equals prekey retention: an attacker who captures packets and later steals a prekey seed reads the packets encrypted to it while the seed is retained, at most `OFFLINE_WINDOW_SECONDS` after its supersession plus its active life. Deleted seeds protect past packets. There is no per-message ratchet and no post-compromise security beyond prekey rotation. Plaintext kept by endpoints, logs and backups is outside this guarantee. **Key compromise.** A stolen signing key lets the thief impersonate the agent and publish descriptors with its own prekey and card records, including `pay` and `erc8004` accounts, until a new `dlg` replaces the key; a verifier that has not seen that `dlg` accepts the key until its `notAfter`, and because a signer may date a record up to `TIMESTAMP_WINDOW_SECONDS` ahead, the owner's re-signed card record can be refused as `stale_descriptor` for that long after the `dlg` ([04](/docs/descriptor#card-record)). Root compromise is handled by pre-rotation ([01](/docs/identity#rotation-and-recovery)). Prekey compromise alone allows reading, not signing. **Relay.** A relay sees metadata: `from`, `to`, `messageId`, `timestamp`, prekey ID (which tells whether a packet went to the recipient's published prekey or to a peer prekey, so receipts, contacts and retries are distinguishable from first-attempt application messages), sizes, timing, IP addresses, KELs, descriptors, card records, permits presented to it and blocks. It cannot read private content, forge packets or receipts, or substitute keys the agent did not sign or that have expired, because keys come only from signed KELs and descriptors that clients verify. It can serve an older descriptor that is still unexpired under the current KEL to a client that has not pinned a newer one, steering senders to an older, still-retained prekey. To a client that has not pinned the identity's KEL it can likewise serve any card record signed under a delegation still within its `notAfter`, including one the owner has since replaced ([04](/docs/descriptor#card-record)). It can drop, delay or reorder packets; the Outbox retries and receipts reveal non-delivery. ACE provides no metadata anonymity. **Wake hints.** A wake registration ([07](/docs/relay#wake-hints)) lets the gateway, and any push service behind it, learn when an identity's queue becomes non-empty, timing the relay already sees; they learn neither sender, content nor ACE ID. The token is not key material: TLS alone protects it, and whoever learns it can only cause spurious wakes. A wake cannot say what arrived: receipts, contact requests and packets the endpoint will discard cause wakes too, so a platform that must show a notification for every wake shows generic text or filters on the device. ACE does not mark packets for this: a relay can already tell packets apart by size and by prekey ID ([§ Relay](#security-considerations)). **Replay and duplication.** Signed timestamps, the offline window and deduplication reject replays; beyond-horizon packets are dropped, never reported as delivered. **Permits.** A permit is a bearer credential. Anyone holding it can enqueue packets naming its subject as sender; such forgeries fail the receiver's signature check but consume the relay capacity granted to that subject until the permit expires. A permit cannot be revoked individually: a block suspends every permit issued to that peer while it stands (an unblocked peer's unexpired permits admit it again, [05](/docs/admission#blocks)), and a new `dlg` voids every permit the issuer has issued. Two `closed` peers that replace their signing keys concurrently void each other's permits, so neither refresh can reach the other and they must pair again by invitation; rotate one side at a time. **Admission recovery.** A receiver honors a permit it issued without its admission table ([05](/docs/admission#admission-recovery)), so a permit that leaked lets its holder make the receiver verify one `source` per packet and never a network lookup; the packet still fails the signature check unless the subject signed it, and nothing is pinned, admitted or held. The recovery data travels inside the encrypted content: the relay learns nothing beyond the permit it already sees at send and that a retry is larger than a first attempt. Relays hold no admission state beyond the permits presented to them and blocks. Removing a peer without blocking it leaves the permits it holds valid; only a block suspends them. **Strangers.** Packets from strangers cost the receiver one decapsulation before authentication (at a `closed` receiver, only packets its relays queued on a permit it issued), and a contact request then costs the verification of a bounded `source` ([05](/docs/admission#receiver-admission)). Relays bound such packets per recipient and per client network ([07](/docs/relay#send)), receivers hold at most `MAX_PENDING_CONTACT_REQUESTS` undecided requests, and `closed` agents never process them. **Hosting.** A hosted MCP service holds keys and sees plaintext. **Network policy.** Relay URLs in descriptors are untrusted input; clients apply the blocked-address policy ([07](/docs/relay#client-rules)). **Authority.** Authentication establishes who sent data. No ACE object authorizes execution or payment; applications decide. Implementations SHOULD keep clocks NTP-synchronized, zero key material after use, never log secrets, and store secrets with owner-only permissions. --- Source: https://aceprotocol.org/docs/relay # 07 — Relay HTTP binding A relay stores opaque packets for registered agents and serves their signed descriptors and card records. It never sees plaintext and is untrusted: clients verify every KEL, descriptor, card record and packet themselves. Any operator can run a relay; agents name theirs in their descriptors. Requests and responses are JSON (`Content-Type: application/json`) unless stated otherwise, and values follow [02](/docs/encoding). Signed objects are closed; responses MAY carry additional members. [`openapi.yaml`](https://github.com/aceprotocol/ace-spec/blob/main/openapi.yaml) renders this chapter for tooling; where they disagree, this chapter wins. ## Endpoints | Endpoint | Auth | Request | 2xx response | |---|---|---|---| | `POST /v1/register` | body signature | RegistrationRequest | `{"ok":true,"status":"registered"\|"idempotent"\|"refreshed"\|"rotated"}` | | `POST /v1/unregister` | `unregister` | — | `{"ok":true}` | | `POST /v1/descriptor` | descriptor signature | PublishedAgent | `{"ok":true}` | | `GET /v1/descriptor/{aceId}` | — | — | PublishedAgent | | `POST /v1/card` | card record signature | CardBundle | `{"ok":true}` | | `GET /v1/card/{aceId}` | — | — | CardBundle | | `GET /v1/handles/{handle}` | — | — | CardBundle | | `GET /v1/cards?tag&profile&cursor&limit` | — | — | `{"cards":[CardBundle],"cursor":string\|null}` | | `POST /v1/blocks` | `block` | `{peer, blocked, revision}` | `{"ok":true}` | | `POST /v1/send` | permit or `open` recipient | `{packet, permit?}` | `{"ok":true}` | | `GET /v1/inbox?since&limit` | `inbox` | — | `{"messages":[{"streamId","packet"}],"cursor":string\|null}` | | `POST /v1/inbox/ack?through` | `inbox-ack` | — | `{"ok":true}` | | `GET /v1/listen?since` | `listen` | — | `text/event-stream` | | `PUT /v1/wake` | `wake` | `{gateway, token}` | `{"ok":true}` | | `DELETE /v1/wake` | `wake` | — | `{"ok":true}` | | `GET /health`, `GET /metrics` | — | — | operations | ## Authentication Header-authenticated requests carry all of: | Header | Value | |---|---| | `X-ACE-Id` | The caller's ACE ID | | `X-ACE-Timestamp` | `^(0\|[1-9][0-9]{0,15})$`, at most `2^53-1` | | `X-ACE-Seq` | The same grammar: the caller's signing delegation `seq` | | `X-ACE-Audience` | The relay's canonical public base URL, at most 2048 characters | | `X-ACE-Nonce` | 32 random bytes as 64 lowercase hex, fresh for every HTTP attempt | | `X-ACE-Signature` | Canonical Base64 ML-DSA-65 signature, exactly 4412 characters | ``` actionPayload' = encodePayload(audience, nonce, actionPayload) signature = delegated signature with aceId = X-ACE-Id, timestamp = X-ACE-Timestamp, seq = X-ACE-Seq, payload = actionPayload', ctx = action ``` `actionPayload` is the action's payload in [02](/docs/encoding#signing-actions); `audience` and `nonce` are the header strings. Header names are case-insensitive. Before reserving the nonce the relay validates headers and arguments, requires an active registration (`not_registered`), compares `X-ACE-Seq` with the registered delegation (lower `stale_seq`, higher `future_seq`), requires its own audience (`invalid_signature`), enforces `|now - timestamp| <= TIMESTAMP_WINDOW_SECONDS` (`stale_timestamp`), enforces signed-while-live and that the registered KEL is usable at `now` (`stale_peer_binding`) and verifies the signature (`invalid_signature`). A failed check consumes no nonce. Each `(aceId, nonce)` is accepted once across all actions and remembered for at least `2 · TIMESTAMP_WINDOW_SECONDS + 1` seconds; a repeat is `409 replay`. The audience is the relay's configured public URL (reference relay: `PUBLIC_RELAY_URL`, required). It is never derived from `Host` or forwarded headers; only explicit loopback development listeners MAY derive it from their socket. ## Client networks Per-network budgets and allowances key on the request's **client network**: the IPv4 /`CLIENT_NETWORK_PREFIX_IPV4` or IPv6 /`CLIENT_NETWORK_PREFIX_IPV6` containing the client address (an IPv4-mapped address counts as IPv4). The client address is the connection's peer address; a relay takes it from a forwarding header only when the connection comes from a proxy its operator configured as trusted. ## Registration `POST /v1/register` takes the closed RegistrationRequest `{aceId, kel, timestamp, audience, signature}`, at most `MAX_PUBLISHED_AGENT_BYTES`, signed with action `register-request`, `aceId`, `timestamp` and payload `encodePayload(lastHashHex, audience)`, where `lastHashHex` is the hex SHA-256 of the final KEL event's canonical bytes and `audience` is the canonical URL ([02](/docs/encoding#relay-urls)) of the relay the request is meant for, so that no relay can replay it to another. The relay checks, at one clock reading, in order: 1. Shape: closed members, `audience` a canonical relay URL, `signature` a delegated signature object (`invalid_registration`). 2. `audience` equals the relay's own audience ([§ Authentication](#authentication); `invalid_signature`). 3. `|now - timestamp| <= TIMESTAMP_WINDOW_SECONDS` (`stale_timestamp`). 4. The KEL verifies and is usable at `now` (`invalid_kel` / `invalid_signature`); `aceId` is its ACE ID (`invalid_registration`). 5. `signature.seq` names its signing delegation (`invalid_signature`); signed while live (`stale_peer_binding`); the signature verifies (`invalid_authorization`). 6. Against a stored record: a fork is `kel_conflict`; a strict prefix of the stored KEL is `stale_seq`. The same timestamp and the same SHA-256 of `M` is `idempotent`; an older timestamp, an equal timestamp with another digest, or a timestamp at or below the unregister barrier is `identity_conflict`. The relay writes identity and KEL atomically and reports `registered` (new), `idempotent`, `refreshed` (newer timestamp, same last event) or `rotated` (KEL extended or recovered). The stored bytes of registrations, descriptors and card records are charged to the byte budget of the client network the identity registered from; a write exceeding it is `rate_limited`. `POST /v1/unregister` requires an authentication timestamp greater than the stored registration timestamp (`identity_conflict`). It deletes the queue, descriptor, card record, blocks and wake registration and closes listen streams, atomically against concurrent registration. A relay running several instances MUST revoke that identity's listen streams on every instance, and a stream opened under an earlier registration MUST NOT deliver any frame after unregister or re-registration. The identity's handle stays bound to it ([§ Descriptors and cards](#descriptors-and-cards)). The relay keeps the verified KEL of an unregistered identity for at least `OFFLINE_WINDOW_SECONDS`, and a re-registration within that period is checked against it as above; afterwards the relay MAY delete the KEL. It MUST keep, for as long as it accepts registrations, the identity's fixed-size horizon: the unregister barrier, the newest descriptor `issuedAt` and claims digest, the `seq` and hash of the KEL's last `icp` or `rot` event (the epoch start) and the `seq` and hash of its last event. Against a horizon whose KEL is deleted, a registration must contain the epoch start (`kel_conflict`) and then either contain the last event (an extension) or contain a `rot` after the epoch start, which only the holder of the committed next root can sign (its recovery, [01](/docs/identity#verifier-rules) rule 6); otherwise it is `stale_seq` when shorter than the retired KEL and `kel_conflict` when not; and its timestamp must exceed the barrier (`identity_conflict`). Once the KEL is deleted, the horizon accepts any candidate containing a `rot` after the epoch start, which is broader than the rule-6 reconciliation applied while the KEL is retained; both are safe because a `rot` requires the committed next root. The horizon's stored bytes are charged as above, permanently. ## Descriptors and cards `POST /v1/descriptor` publishes a PublishedAgent. The relay verifies it ([04](/docs/descriptor#verification)); requires its `kel` to equal the registered KEL (`not_registered`, `kel_conflict`) and its `relays` to include this relay's public URL (`invalid_argument`); and requires `issuedAt` to be newer than the stored descriptor's, or equal with identical canonical claims (idempotent), else `stale_descriptor`. The relay keeps the newest `issuedAt` and claims digest as long as it keeps the identity's KEL. Its stored bytes are charged as [§ Registration](#registration) states. `POST /v1/card` publishes a CardBundle, authenticated by its own signature. The relay verifies it ([04](/docs/descriptor#card-record)); requires its `kel` to equal the registered KEL (`not_registered`, `kel_conflict`); and requires `issuedAt` to be newer than the stored record's, or equal with identical canonical claims (idempotent), else `stale_descriptor`. Handles are set once ([04](/docs/descriptor#card-record)): - A record whose `home` is another relay claims nothing here; a handle already bound to the identity here stays bound. - When `home` is this relay's public URL and a handle is bound to this identity here, the record MUST carry exactly that handle; a record that omits it or carries another is `invalid_argument`. - A relay whose public URL has a path binds no handle: a record naming it as `home` carries none ([04](/docs/descriptor#card-record)). - Otherwise, when `home` is this relay's public URL and the record names a handle, the relay binds it: a handle bound to no identity is bound to this one, permanently; a handle bound to another identity is `handle_taken`. A relay MAY reserve handles under its naming policy; a reserved handle is `handle_taken`. A handle's skeleton is the handle with its hyphens removed, `0` read as `o` and `1` as `l`; a relay SHOULD treat a handle whose skeleton equals that of a handle bound or reserved here as reserved, so `alice-bot`, `alicebot` and `a1icebot` cannot be held by different identities. Binding is limited to `HANDLE_CLAIMS_PER_NETWORK_PER_DAY` per client network ([§ Client networks](#client-networks); `rate_limited`). An identity therefore holds at most one handle, and a handle is never reassigned: a new identity can only claim a handle no identity has held. The record, the handle binding and their effects on directory and send change atomically. Handle bindings and card records do not expire; a card record is deleted only by unregister, and the handle binding survives it. The relay keeps the newest card record `issuedAt` and claims digest as long as it keeps the identity's KEL, so a deleted record cannot be replayed over a newer one. Its stored bytes are charged as [§ Registration](#registration) states. | Lookup | Returns | Otherwise | |---|---|---| | `GET /v1/descriptor/{aceId}` | the unexpired PublishedAgent of a registered identity | `unknown_peer` | | `GET /v1/card/{aceId}` | the stored CardBundle of a registered identity | `unknown_peer` | | `GET /v1/handles/{handle}` | the stored CardBundle of the identity the handle is bound to, when that record's `home` is this relay | `unknown_peer` | A relay treats a stored descriptor or card record whose `kel` is not the identity's current registered KEL as absent (`unknown_peer`), also on publish: its `issuedAt` does not bound a new one. Clients check a resolved handle as [04](/docs/descriptor#card-record) states. `GET /v1/cards` is the public directory: the stored CardBundles of identities whose card record is `listed: true`, has `home` equal to this relay's public URL and whose descriptor is unexpired, ordered by ACE ID. Two optional filters narrow it: `tag`, an exact card tag the record lists, and `profile`, an exact profile ID the stored descriptor lists. The profile filter is a hint: a client obtains the descriptor before connecting ([05](/docs/admission#flow)) and relies on what that descriptor lists. `limit` is 1..`MAX_INBOX_PAGE` (default `MAX_INBOX_PAGE`); a page is at most `MAX_PAGE_BYTES` and holds at least one entry when any remain; `cursor` is opaque and continues after the last returned agent; the final page has `cursor: null`. The directory is a complete, stable listing for crawling; ranking and text search over it are services built on it, not relay functions. ## Blocks `POST /v1/blocks` takes the closed `{peer, blocked, revision}` (`peer` an ACE ID other than the caller, `blocked` a boolean, `revision` a wire integer), authenticated with action `block`. `revision` MUST exceed the stored revision for `(caller, peer)` (`stale_revision`). The relay keeps the revision even after `blocked: false`. It MAY cap tracked peers per identity at no fewer than `MIN_BLOCK_CAP` (`limit_exceeded`). ## Send `POST /v1/send` takes the closed `{packet, permit?}`, at most `MAX_SEND_BODY_BYTES` bytes. The relay does not verify the packet signature and needs no sender KEL; the receiver verifies. In order: 1. The packet decodes ([03](/docs/packet#decoding); `invalid_packet`, `unsupported_version`) and `now - OFFLINE_WINDOW_SECONDS <= timestamp <= now + TIMESTAMP_WINDOW_SECONDS` (`stale_timestamp`). 2. `to` is registered here and has not blocked `from` (`not_permitted`). 3. A byte-identical packet already queued for `to` returns `{ok:true}` without enqueuing. 4. **With a permit:** the permit's `issuer` is `to`, its `subject` is `from`, it verifies as an operational signature of `to`'s registered KEL at `now` ([01](/docs/identity#operational-signatures)) and is valid (`not_permitted`). The packet enters the normal queue, retained `OFFLINE_WINDOW_SECONDS` and bounded per recipient in bytes and entries (`recipient_inbox_full`). 5. **Without a permit:** `to` has a stored card record here with `contact: open` and an unexpired descriptor (`not_permitted`). The packet enters the stranger allowance: at most `STRANGER_ALLOWANCE` entries per recipient, of which a relay MUST accept at most `STRANGER_NETWORK_ALLOWANCE` from one client network (`recipient_inbox_full`), each retained `CONTACT_REQUEST_TTL_SECONDS`, and rate-limited per client network and per recipient (`rate_limited`). Every `not_permitted` of this route carries the same `message`, so a caller cannot tell an unknown, `closed` or blocking recipient apart. Storage is charged to the recipient that authorized it and to the client network it registered from: a relay MAY bound each sender's share of a recipient's queue (`recipient_inbox_full`) and the queued bytes of all recipients registered from one network (`rate_limited`), and MUST NOT key relay-wide storage limits on the unauthenticated `from`; it MAY refuse sends with `rate_limited` as a last resort before its store is exhausted. Both kinds share the recipient's stream. The relay then notifies listeners. ## Inbox `GET /v1/inbox?since&limit` (action `inbox`) returns the caller's entries after `since` in stream order without deleting them. `since` is a stream ID or `-` (default, from the start); `limit` is 1..`MAX_INBOX_PAGE` (default `MAX_INBOX_PAGE`); the signed payload uses `sinceOrDash` and the effective `decimal(limit)`. A page is at most `MAX_PAGE_BYTES` and holds at least one entry when any exists; `cursor` is the last returned stream ID or `null`. A short non-empty page is not the end; continue until a page is empty. `POST /v1/inbox/ack?through` (action `inbox-ack`, payload `encodePayload(through)`) deletes the caller's entries up to and including the stream ID `through`. It is idempotent. Endpoints acknowledge as [06](/docs/delivery#receive-pipeline) specifies. ## Listen `GET /v1/listen?since` (action `listen`) opens a Server-Sent Events stream. It first sends `event: connected`, replays entries after `since` as `catchup` events, then sends new entries as `message` events: ``` id: event: catchup | message data: {"packet": } ``` `event: drain` asks the client to reconnect; lines starting with `:` are heartbeats. Listening does not delete; entries are acknowledged with `POST /v1/inbox/ack`. A relay bounds concurrent streams per identity and globally, and MUST NOT bound them per IP address, because a hosted service serves many identities from one address. ## Wake hints Optional: a relay that does not offer them answers both routes with `404 not_found`. A wake tells a gateway the recipient chose that its queue has entries, so that a suspended endpoint can be woken to fetch them; it carries no content, ACE ID or count. Endpoints MUST NOT depend on wakes: delivery is defined by [§ Inbox](#inbox) and [§ Listen](#listen) alone. `PUT /v1/wake` (action `wake`, payload `encodePayload(gateway, token)`) takes the closed `{gateway, token}` and replaces the caller's registration; `DELETE /v1/wake` (action `wake`, empty payload) removes it. Both are idempotent. `gateway` is a canonical relay URL ([02](/docs/encoding#relay-urls)) and `token` matches `^[A-Za-z0-9_-]{22,512}$` (`invalid_argument`). The gateway issues the token: an unguessable value (at least 128 random bits) that it maps to the devices to wake. A relay SHOULD accept only gateways its operator lists (`invalid_argument`). An identity has at most one registration; its stored bytes are charged as [§ Registration](#registration) states. Unregistering deletes it, and it expires `OFFLINE_WINDOW_SECONDS` after the identity's last authenticated `wake`, `inbox`, `inbox-ack` or `listen` request. **Arming.** Storing a registration arms it, and every authenticated `inbox`, `inbox-ack` or `listen` request of the identity re-arms it. When an entry is queued for an identity whose registration is armed and that has no open listen stream, the relay disarms it and sends one wake, so one wake covers every entry queued until the endpoint next fetches or acknowledges. A relay MAY defer a wake to keep a minimum interval between wakes of one identity; it defers it rather than dropping it. **Request.** A wake is `POST` to `gateway` with `Content-Type: application/json` and the body `{"token": }`, nothing else. The relay sends it after answering the send that queued the entry, so whether a recipient has a registration MUST NOT change the response or timing of `POST /v1/send`. It applies the blocked-address policy of [§ Client rules](#client-rules) to the gateway host, connects only to a validated address, never follows redirects, bounds the request in time and retries it at most once. A `2xx` response is success; `404` or `410` means the gateway no longer knows the token, and the relay deletes the registration if it still holds that token; any other outcome is ignored. **Gateways.** The token is a bearer capability whose only power is to cause wakes. A gateway MAY also authenticate relays by arrangement with their operators (mutual TLS, source addresses); ACE defines no relay credential. ## Operations `GET /health` and `GET /metrics` are operator-defined. ## Errors Error bodies are `{"error": , "message"?: string}`. | Code | Status | Meaning | |---|---|---| | `invalid_argument` | 400 | Malformed body, parameter or header; wrong content type; descriptor not listing this relay; card record not carrying the identity's handle | | `invalid_packet` | 400 | Packet fails [03](/docs/packet#decoding) | | `unsupported_version` | 400 | Packet `ace` is not `"1"` | | `invalid_registration` | 400 | Registration shape or `aceId` mismatch | | `invalid_kel` | 400 | KEL fails [01](/docs/identity#verifier-rules) or is not usable | | `stale_timestamp` | 400 | Timestamp outside its window | | `invalid_signature` | 401 | Authentication or KEL signature fails, wrong audience (authentication or registration), or registration `seq` not the delegation | | `invalid_authorization` | 401 | Registration signature fails, or a descriptor or card record fails [04](/docs/descriptor#verification) or [04](/docs/descriptor#card-record) | | `stale_seq` | 401 | Superseded delegation `seq`, or a registration KEL that is a strict prefix of the stored one | | `stale_peer_binding` | 401 | Signed before the delegation's `ts` or at or after its `notAfter`, or the signer's KEL is not usable at `now` | | `not_registered` | 403 | Caller or publisher not registered here | | `not_permitted` | 403 | Missing, invalid or expired permit, unknown or `closed` recipient on send, or blocked sender | | `unknown_peer` | 404 | Unknown or expired target, or a handle whose identity has no card record here naming this relay as `home` | | `not_found` | 404 | No such route, or wake hints not offered | | `replay` | 409 | Nonce already used | | `identity_conflict` | 409 | Registration or unregister ordering violated | | `future_seq` | 409 | `X-ACE-Seq` ahead of the registered KEL | | `kel_conflict` | 409 | KEL forks the stored one, or descriptor or card KEL differs from the registered KEL | | `stale_descriptor` | 409 | Descriptor or card record not newer than the stored one | | `handle_taken` | 409 | Handle bound to another identity, or reserved by the relay | | `stale_revision` | 409 | Block revision not increasing | | `limit_exceeded` | 409 | Tracked-block cap reached | | `payload_too_large` | 413 | Body over its limit | | `rate_limited` | 429 | Rate, connection or byte-budget limit; `Retry-After` gives seconds | | `recipient_inbox_full` | 429 | Recipient queue, the sender's share of it, or the stranger allowance is full; transient, retried with backoff (at least `Retry-After`) | | `internal_error` | 500 | Unexpected failure; MAY be retried | ## Client rules **Responses.** Clients MUST NOT follow redirects. A `409 replay` is retried once with a fresh nonce. Any other unexpected response maps to an SDK error ([06](/docs/delivery#error-codes)), first match wins: | Status | Relay `error` | SDK code | |---|---|---| | 1xx, 3xx, unexpected 2xx, ≥ 600 | any | `relay_protocol_error` | | 408, 5xx | any | `relay_unavailable` | | 429 | any (`rate_limited`, `recipient_inbox_full`, other or absent) | `relay_unavailable` | | 403 | `not_registered` / `not_permitted` | `not_registered` / `not_permitted` | | 404 | `unknown_peer` | `unknown_peer` | | other 4xx | any | `relay_rejected` | The relay `error` is the body's `error` string member, if any; the SDK error keeps it and the HTTP status. `Retry-After` is honored only as delay-seconds (`^[0-9]+$`) and only for transient codes. A successful body or SSE `data` is parsed within `MAX_JSON_DEPTH + 3` and each packet, PublishedAgent or CardBundle in it checked within `MAX_JSON_DEPTH` ([02](/docs/encoding#json-values)); a violation is `relay_protocol_error` for the whole response, never a skipped entry. **Listen.** Lines end with CR, LF or CRLF; an event is dispatched only if it has a `data` field, and `id`/`event` apply only to their own event. A connection makes progress when it delivers `catchup`, `message` or `drain`. After `drain` or a clean end following progress, reconnect immediately with `since` set to the last processed `id` and a fresh nonce; otherwise back off 1, 2, 4 … 30 seconds (at least `Retry-After`); 10 consecutive failures end listening with `relay_unavailable`. **Blocked addresses.** Clients MUST resolve the hosts of relay URLs taken from descriptors, refuse any blocked address, connect only to a validated address and re-check on reconnect (`blocked_address`). IPv4-mapped (`::ffff:0:0/96`) and NAT64 (`64:ff9b::/96`) addresses are judged by the embedded IPv4 address; other IPv4-embedding IPv6 ranges are blocked whole; a non-literal or zone-qualified IPv4 input is blocked. Explicit development configuration MAY allow loopback. - IPv4: `0.0.0.0/8`, `10.0.0.0/8`, `100.64.0.0/10`, `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.0.0.0/24`, `192.0.2.0/24`, `192.168.0.0/16`, `198.18.0.0/15`, `198.51.100.0/24`, `203.0.113.0/24`, `224.0.0.0/4`, `240.0.0.0/4`. - IPv6: `::/96`, `::ffff:0:0:0/96`, `64:ff9b:1::/48`, `100::/64`, `2001::/23`, `2001:db8::/32`, `2002::/16`, `3fff::/20`, `5f00::/16`, `fc00::/7`, `fe80::/10`, `fec0::/10`, `ff00::/8`. --- Source: https://aceprotocol.org/docs/profiles # 08 — Profiles A profile gives application meaning to private content. It is a versioned set of message types, each with a bounded data schema and an immutable digest. A profile never changes identity verification, admission, deduplication or delivery, and installing one never grants execution authority. There is no central catalogue: official and third-party profiles use the same mechanism. ## Installation and announcement An endpoint installs a manifest only through its local management interface. Receiving a manifest, URI, descriptor or message MUST NOT install anything, and implementations never fetch schema references or run downloaded validators. The core profile is always installed. An agent lists each other enabled profile in its descriptor `profiles` as `{id, digest}` with the profile digest ([04](/docs/descriptor#fields)). Two endpoints share a profile only when ID and profile digest are both equal; an equal ID with another digest is incompatible. Implementations MAY bound installed manifests. An endpoint MUST reject a manifest that defines a message type an installed manifest already defines. ## Manifest format A manifest is the closed object `{id, messages}` whose canonical JSON is at most `MAX_MANIFEST_BYTES`. `id` and every message `type` are namespaced identifiers ([02](/docs/encoding#identifiers)); third-party manifests MUST NOT use the reserved `ace:` prefix. `messages` holds 1–32 closed objects `{type, schema}` with distinct types; each `schema` has `type: "object"`. An invalid manifest is `invalid_argument`. ### Schema dialect `schema` is a bounded subset of JSON Schema, not a general interpreter. Each schema node is a closed object with `type` (one of `object`, `array`, `string`, `integer`, `boolean`, `null`), an optional `enum`, and only these type-specific keywords: | Type | Keywords | Matches | |---|---|---| | `object` | `properties` (object, at most 64 names), `required` (distinct names present in `properties`), `additionalProperties` (boolean) — all three REQUIRED | A JSON object containing every `required` name, whose listed members match their schemas, and which has no other member unless `additionalProperties` is `true` (other members are then unchecked) | | `array` | `items` (REQUIRED schema), `maxItems` (optional, ≥ 0) | A JSON array of at most `maxItems` elements, each matching `items` | | `string` | `maxLength` (optional, ≥ 0, in code points) | A JSON string | | `integer` | `minimum`, `maximum` (optional, inclusive, `minimum <= maximum`) | A JSON number whose value is an integer in `[-(2^53-1), 2^53-1]` and within the bounds | | `boolean`, `null` | none | `true`/`false`; `null` | All bounds are integers in `[-(2^53-1), 2^53-1]`. Schemas nest at most 8 edges below the root. `enum` holds 1–64 values, each matching the node without its `enum`; a value matches `enum` when its canonical JSON equals that of a member. A JSON `null` matches only a `null` schema: an optional member is omitted, never `null`. Unknown or inapplicable keywords, `$ref` and any loader or code are rejected. ## Digests There is one digest algorithm, over canonical JSON ([02](/docs/encoding#canonical-json)): ``` profile digest = lowercaseHex(SHA-256(canonicalStateBytes(manifest))) message digest = lowercaseHex(SHA-256(canonicalStateBytes({type, schema}))) ``` The profile digest identifies a profile in descriptors and contact requests. The message digest is the `schemaDigest` of private content ([03](/docs/packet#private-content)) and binds its type. Changing any part of a manifest changes its digest; incompatible semantics SHOULD use a new versioned ID. A digest is an agreement identifier, not evidence that a service is trustworthy. ## Official manifests The official manifests are the files in [`profiles/`](https://github.com/aceprotocol/ace-spec/blob/main/profiles). Every SDK bundles these exact files and computes digests from them; they are exempt from the reserved-prefix rule. | File | Profile ID | Profile digest | Announced | |---|---|---|---| | [core.json](https://github.com/aceprotocol/ace-spec/blob/main/profiles/core.json) | `ace:core:1` | `66f46d4b8951b75f98abae24a72ceffa173c4e27748d09be78b8d6382ddc8222` | never; always installed | | [text.json](https://github.com/aceprotocol/ace-spec/blob/main/profiles/text.json) | `ace:text:1` | `48619350eaea46b2d60f7ce71fe55867e00c8dd081796b408fb6a95692b8b307` | when enabled | | [delegation.json](https://github.com/aceprotocol/ace-spec/blob/main/profiles/delegation.json) | `ace:delegation:1` | `fc1653477df13c6c8be00d34ce4811d90365053c0afd2b858f90603eab474964` | when enabled | | [commerce.json](https://github.com/aceprotocol/ace-spec/blob/main/profiles/commerce.json) | `ace:commerce:1` | `30583f0a1acd86ac5a217d706df1c27dc7caea5d4f2deed6112807679afab053` | when enabled | | Message type | Message digest | Rules | |---|---|---| | `ace:receipt:1` | `a79fea8c61d30c484fdc9ba71fee310c612da84f84827fe2c4b2e99e5708d3e3` | [06](/docs/delivery#receipts) | | `ace:contact:1` | `29fc76bbaa5a437aa068f823b1f2b2466c89fed6b2ea4b9cfa7c31cc80df15e9` | [05](/docs/admission#contact-requests) | | `ace:text:1` | `e91541145d670369cf651d062e6bb8a94ce2605d4a9f2261ceef1d8a29c2e7da` | none: `{message}` plain text | | `ace:delegation:1` | `4beba7c7640936f14d9eb90843726f6a53afc75c6798cb02fa914c09af5b24b1` | [delegation](/docs/delegation-profile) | | `ace:commerce:rfq:1` | `7b3fc71d8e538ff2f5ca0189d81feb237aa31e1f49787e951b90e9e17c1207d5` | [commerce](/docs/commerce) | | `ace:commerce:offer:1` | `0fb206d333aa41734407402ce4e44157720c39a9ffc4325750ebfb8d0c7f8179` | [commerce](/docs/commerce) | | `ace:commerce:accept:1` | `59e5e6fd0df886bab94d439f138cbaf1990c3af7cfe44484581c501d37e76c23` | [commerce](/docs/commerce) | | `ace:commerce:reject:1` | `ad51a8ad82f9bdde6b2d7f709430836359b09be66b32cfe4d8352d8c84a8dff6` | [commerce](/docs/commerce) | | `ace:commerce:invoice:1` | `4020571cb3dbbb4e104b13e5e37a54e1b426d989e101f5c3b3de49fc680739a5` | [commerce](/docs/commerce) | | `ace:commerce:payment:1` | `33a1fde87d16bf5ad5d4387d73e0823ed14ccd15149f51bee938b54be8d95ff5` | [commerce](/docs/commerce) | | `ace:commerce:deliver:1` | `ff1d8025af3f87eee33afb8d2d88452365c41c84b55bba13e96fbf94907b9fd2` | [commerce](/docs/commerce) | | `ace:commerce:confirm:1` | `876f22d60311651917fb7aee630368b885b8378fa1d0ac9dd91b17a65d8800d7` | [commerce](/docs/commerce) | A third-party example is [examples/translation-profile.json](https://github.com/aceprotocol/ace-spec/blob/main/examples/translation-profile.json). ## Profile rules Rules that a schema cannot express (references between messages, state machines, embedded signed objects) are **profile rules**. A rule governs a set of message types and sees, for each message, the event `{direction: "send" | "receive", from, to, messageId, timestamp, type, threadId?}` (for `send`, `timestamp` is the time the Outbox entry was created) and the body. It has two steps: - **check** runs on `send` before the Outbox entry is persisted (before encryption for receipts and contacts, which bypass the Outbox) and on `receive` at pipeline step 10 ([06](/docs/delivery#receive-pipeline)). It reads state only and fails with an ACE error whose code becomes the `rejected` receipt code. - **commit** applies state changes, idempotently per `(from, messageId)`. On `receive` it runs inside the transaction that persists the delivery record; on `send`, as below. ### Sender state and busy threads A message is **governed** when some installed rule lists its type. For a governed message with a `threadId`, the thread `(peer, threadId)` is **busy** while such a message from the local agent is pending in its Outbox. The sender: 1. runs `check` at enqueue; enqueueing another governed message on a busy thread fails with `thread_busy` before `check`, and nothing is stored; 2. runs `commit` only when the `delivered` receipt for the message arrives, so its state follows what the receiver applied; 3. changes no rule state on a `rejected` receipt; the thread is free again; 4. frees the thread without any state change when the message expires or is abandoned; the outcome is then unknown, and a late `delivered` receipt still runs `commit` ([06](/docs/delivery#outbox-sender)). On receive, a governed message on a thread that is busy towards its sender is rejected with `thread_busy` at step 10, before `check`. When both parties send on one thread at once, both messages are therefore rejected and neither side's state changes; the parties then continue from the unchanged state. A message without a `threadId`, or of a type no rule governs, never makes a thread busy. The core rules for receipts and contacts are always active. Rules for official profiles are optional modules ([commerce](/docs/commerce#threads)). The core pipeline contains no profile-specific branches. --- Source: https://aceprotocol.org/docs/commerce # Commerce profile `ace:commerce:1` An optional official profile: a shared vocabulary for requesting quotes, agreeing terms, invoicing, proving payment and delivering work. Its manifest is [commerce.json](https://github.com/aceprotocol/ace-spec/blob/main/profiles/commerce.json) ([digests](/docs/profiles#official-manifests)); its state rules are profile rules ([08](/docs/profiles#profile-rules)). **A received commerce message never authorizes a payment.** Whether to pay, what to deliver and whether an amount is acceptable are application decisions. Settlement happens outside ACE; messages carry instructions and evidence only. ## Messages Every commerce message carries a `threadId` ([private content](/docs/packet#private-content)). Field schemas are in the manifest; this table adds the rule-level constraints that the commerce rules enforce (`invalid_body` on failure). | Type | Required | Optional | Rule-level constraints | |---|---|---|---| | `ace:commerce:rfq:1` | `need` | `maxPrice`, `currency`, `expiresAt` | — | | `ace:commerce:offer:1` | `price`, `currency` | `terms`, `expiresAt` | — | | `ace:commerce:accept:1` | `offerId` | | [§ References](#references) | | `ace:commerce:reject:1` | | `reason` | — | | `ace:commerce:invoice:1` | `offerId`, `amount`, `currency`, `settlement` | | `settlement` is exactly one of the forms in [§ Settlement](#settlement) | | `ace:commerce:payment:1` | `referenceId`, `amount`, `currency`, `settlement` | | `settlement` is `{method, proof}` per [§ Settlement](#settlement) | | `ace:commerce:deliver:1` | `type` | `content`, `contentType`, `uri`, `metadata` | `type: inline` requires `content`; `type: reference` requires `uri` | | `ace:commerce:confirm:1` | `deliverId` | `message` | — | Amounts and prices are decimal strings, never JSON numbers. `expiresAt` is a wire integer in Unix seconds; at or after it an RFQ is closed and an offer SHOULD be treated as withdrawn. Receivers SHOULD check that an invoice's `amount` and `currency` match the accepted offer and that a payment matches the invoice; mismatches are application decisions, not protocol errors. `deliver` does not prescribe how content is hosted; `metadata` SHOULD carry `checksum` and `expiresAt` for references. ## Settlement Stablecoins are the primary settlement method; Stripe Link is supported for card and bank payments. | Method | Invoice `settlement` | Payment `settlement.proof` | |---|---|---| | `stablecoin` | `{method: "stablecoin", asset, recipient, memo?}` — `asset` is a CAIP-19 asset ID (`^[-a-z0-9]{3,8}:[-_a-zA-Z0-9]{1,32}/[-a-z0-9]{3,8}:[-.%a-zA-Z0-9]{1,128}$`), `recipient` a non-empty address on that chain | `{txHash}`, non-empty; the chain is the asset's | | `stripe-link` | `{method: "stripe-link", url}` — `url` an ACE HTTPS URL ([02](/docs/encoding#https-urls)) | `{paymentId}`, non-empty | No other members are allowed for a method. A payment's `settlement.method` names the method used. ## Descriptor extension An agent that enables this profile MAY publish `ext["ace:commerce:1"]` ([04](/docs/descriptor#extensions)): the closed object `{settlement}`, where `settlement` holds 1–10 distinct entries, each `{method: "stablecoin", asset}` (CAIP-19 as above) or `{method: "stripe-link"}`. It advertises accepted methods and confers no authority. ## Threads State is kept per `(local pair key, threadId)` ([03](/docs/packet#decoding)), so threads of different peers never mix. Below, `rfq` stands for `ace:commerce:rfq:1`, and likewise for the other types. ### Parties and roles A thread has exactly two distinct parties, fixed by its first message (`from`, `to`). Every later message is between the same parties, in either direction. The sender of the `rfq` is the **buyer**; the other party is the **seller**. | From state | Type | Sender | To state | |---|---|---|---| | `idle` | `rfq` | buyer | `requested` | | `requested` | `offer` | seller | `offered` | | `requested` | `reject` | seller | `rejected` | | `offered` | `offer` | seller | `offered` (counter-offer supersedes) | | `offered` | `accept` | buyer | `accepted` | | `offered` | `reject` | buyer | `rejected` | | `accepted` | `invoice` | seller | `invoiced` | | `accepted` | `payment` | buyer | `paid` (pre-paid) | | `accepted` | `deliver` | seller | `delivered` (deliver-first) | | `invoiced` | `payment` | buyer | `paid` | | `paid` | `deliver` | seller | `delivered` | | `delivered` | `confirm` | buyer | `confirmed` | `rejected` and `confirmed` are terminal. Every other `(state, type)` pair is rejected. Messages of other profiles in the same thread are always allowed and never change state. Each accepted commerce message appends `{type, messageId, timestamp, from}` to the thread history; the last entry is the **head**. ### References | Field | MUST equal the `messageId` of | |---|---| | `accept.offerId` | the head (the latest offer) | | `invoice.offerId` | the entry immediately before the `accept` (the accepted offer) | | `payment.referenceId` | the head: the `invoice` when `invoiced`, the buyer's own `accept` when pre-paid | | `confirm.deliverId` | the head (the `deliver`) | ### Check order `check` (on send before the Outbox entry is persisted, on receive at pipeline step 10) applies, first failure wins, without changing state: 1. `threadId` absent → `invalid_body`. 2. The local agent is not exactly one of `from` and `to` → `wrong_party`. 3. The thread has history and its parties are not `{from, to}` → `wrong_party`. 4. The state is terminal, or `(state, type)` is not in the table → `transition_not_allowed`. 5. The sender does not have the required role → `wrong_role`. 6. A reference does not match [§ References](#references) → `bad_reference`. 7. A bound is reached → `limit_exceeded`. `commit` applies the transition: on receive with the delivery record, on send when the `delivered` receipt arrives. A thread with a commerce message in flight is busy, and crossing messages (for example an accept racing a counter-offer) are both rejected with `thread_busy`, leaving both parties' state unchanged ([08](/docs/profiles#sender-state-and-busy-threads)). ### Bounds and retention - At most `1000` non-terminal threads per peer; a message opening another is `limit_exceeded`. Implementations MAY bound history length the same way. They MUST NOT discard thread state to make room. - A terminal thread MAY be deleted once its head is older than 30 days (2,592,000 seconds). A non-terminal thread in which the local agent has sent nothing MAY be deleted after the same period. Any other non-terminal thread MUST be kept. --- Source: https://aceprotocol.org/docs/delegation-profile # Delegation profile `ace:delegation:1` An optional official profile: signed credentials by which one identity declares that another acts on its behalf. Its manifest is [delegation.json](https://github.com/aceprotocol/ace-spec/blob/main/profiles/delegation.json) ([digests](/docs/profiles#official-manifests)); its message rule is a profile rule ([08](/docs/profiles#profile-rules)). ## Credentials A **credential** is the closed object: ``` { issuer, subject, issuedAt, expiresAt, signature } ``` - `issuer` and `subject` are ACE IDs ([02](/docs/encoding#identifiers)); `issuer != subject`. - `issuedAt` and `expiresAt` are wire integers: `issuedAt <= now + TIMESTAMP_WINDOW_SECONDS`, `issuedAt < expiresAt`, `expiresAt - issuedAt <= 604800`. - Signed by the issuer as a delegated signature ([02](/docs/encoding#delegated-signatures)) with action `ace:delegation:1`, `aceId = issuer`, `timestamp = issuedAt` and payload `canonicalStateBytes(claims)`. A credential states one edge: **the issuer declares that the subject acts on its behalf.** It grants no execution, payment or communication right. The issuer revokes by not renewing. ## Descriptor extension `ext["ace:delegation:1"]` is the closed object `{credentials}`: 1–4 credentials with distinct `issuer`s, each with `subject` equal to the descriptor's `aceId`. Descriptor verification ([04 § Extensions](/docs/descriptor#extensions)) checks this shape only: each entry is a closed credential with `issuer != subject`, `subject` equal to the descriptor's `aceId`, `issuedAt <= now + TIMESTAMP_WINDOW_SECONDS`, `issuedAt < expiresAt` and `expiresAt - issuedAt <= 604800`; it checks neither expiry (`now < expiresAt`) nor any signature. ## Verifying a credential A client that needs to know whether `subject` acts for `issuer` pins the issuer by looking up its descriptor ([04 § Verification](/docs/descriptor#verification), [01 § Pinning](/docs/identity#pinning)), then requires `now < expiresAt` and the signature to verify as an operational signature of the issuer's pinned KEL ([01 § Operational signatures](/docs/identity#operational-signatures)). A lookup or pin failure is reported as that failure; it is never read as "not delegated". The result is bound to the pin: when the issuer's pin changes, the credential is verified again. ## Message `ace:delegation:1` The body `{credential}` hands a credential from its issuer to its subject. - **check** (on send, and on receive at pipeline step 10 of [06](/docs/delivery#receive-pipeline)): the credential is closed and meets the `issuer != subject`, `issuedAt` and `expiresAt` rules of [§ Descriptor extension](#descriptor-extension), `now < expiresAt`, `credential.issuer` equals `from`, `credential.subject` equals `to`, and on receive its signature verifies as an operational signature of the sender's pinned KEL. Any failure is `invalid_body`. - **commit** changes no state. The application stores the credential and publishes it in its `ext`. --- Source: https://aceprotocol.org/docs/sdk-typescript # TypeScript SDK TypeScript SDK for the **ACE Protocol**: post-quantum agent identity, asynchronous end-to-end encrypted packets and profile-based interaction vocabularies. ACE lets agents express intent reliably, learn which interaction rules a peer supports, and exchange information safely. Who to work with, what to agree and whether to pay or execute anything are application decisions: no ACE object authorizes execution or payment. Spec: `ace-spec` chapters 00–08 (packet version `"1"`). Every signature is ML-DSA-65 over `M = encodePayload("ace.v1", aceId, decimal(timestamp), seqOrEmpty, payload)` with the action as the ML-DSA context. ## What is inside - **Identity** — key event log (`icp`/`rot`/`dlg`; `dlg` delegates the ML-DSA-65 signing key only), ACE IDs, pre-rotation and recovery. `SoftwareIdentity` exports root, next-root and signing seeds plus the KEL. - **Prekeys** — `PrekeyVault` (`current`, `rotate`, `open`, `prune`): X-Wing prekeys published in the descriptor, superseded seeds retained for `OFFLINE_WINDOW_SECONDS` (at most 16). `StorePrekeyVault` persists them in an `ACEStore` (a 0600 file with `FileStore`, a `MemoryStore` for ephemeral agents; optional `protect` sealing hook). - **Packet** — `createPacket`, `decodePacket`, `verifyPacketSignature`, `decryptPacket` (the content bytes) and `decodePrivateContent`: X-Wing → HKDF-SHA256 (empty salt, info = header binding `from`, `to`, `messageId`, `timestamp`, prekey and KEM ciphertext) → AES-256-GCM, signed with ML-DSA-65 (action `packet`). The recipient need not be online. - **Card record** — `createCardRecord` / `verifyCardBundle`: the durable identity record `{kel, card}` with its home relay, set-once handle (`^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$`, 3–32 characters), card (`description`, `image`, `tags`; no name: a display name is application data), `pay` (CAIP-10 accounts), `erc8004` (ERC-8004 registrations; `verifyErc8004Registration` checks a claim against the registration file's `services`, the host reads the chain), `contact` (`open` takes stranger contact requests) and `listed` (directory). No expiry; re-signed after a new `dlg`. `NetworkClient.lookupCard` resolves an ACE ID or `@name@host` (typed without the `@` or in any ASCII case is accepted); it looks the live descriptor up first and pins its KEL, and fails with the descriptor's error (no live descriptor: `unknown_peer`). `NetworkClient.discover` / `RelayClient.directory` read the directory (`GET /v1/cards`, optional `tag`, `profile` hint, `limit` 1–100, default 100): listed card records, each verified on its own. - **Descriptor** — `createDescriptor` / `verifyPublishedAgent`: relays, prekey, profiles and `ext` (official profiles' values checked by their validators). Every operational signature is checked by one `verifyOperationalSignature` (01 rules 1–5: signed at or after the delegating `dlg`'s `ts` and before its notAfter, the signer usable now); every signer signs at `max(now, ts of its last KEL event)`. - **Admission** — 30-day delivery permits, contact requests as `ace:contact:1` packets, blocks, and invitations for out-of-band pairing (`NetworkClient.createInvitation` admits the invitee; `importInvitation` verifies the invitation, pins the inviter and holds its permit, and never admits). - **Delivery** — `Outbox` (persisted, re-encrypted per attempt, backoff 60 s → 3600 s, expires after the offline window; only a receipt confirms delivery; `prune` keeps settled entries for the offline window), `Inbox` (admission before any lookup, schema and profile-rule checks, `rejected:` receipts, beyond-horizon packets dropped without a receipt, poison quarantine), `RelayClient` for the relay HTTP binding. - **Profiles** — the official manifests (`ace:core:1`, `ace:text:1`, `ace:commerce:1`, `ace:delegation:1`) are bundled from `ace-spec/profiles/` (`npm run gen:profiles`); `ProfileSet` installs them or third-party manifests; `ProfileRules` hooks carry profile semantics (`check` at enqueue, send-side `commit` on the `delivered` receipt; a thread with a rule-governed message in flight is `thread_busy`). The optional commerce module provides `commerceRules()` and `ThreadStore`. The optional delegation module (`ace:delegation:1`) provides `createDelegationCredential`, `validateDelegationExt` (the descriptor extension `ext['ace:delegation:1']`, shape only), `verifyDelegationCredential` and `delegationRules()`; `NetworkClient.verifyDelegation` pins the issuer and verifies a credential under that pin. A credential grants no execution, payment or communication right. - **Glue** — `NetworkClient.open(config)`: the store (an `ACEStore` or a directory), the home relay (`relayUrl` or a `RelayClient`), installed profiles (names or manifests; `'commerce'` adds `commerceRules` and `client.threads`, `'delegation'` adds `delegationRules`) and a `MessageLog` (`client.messages`: indexed unread cap, ack, filters). Methods: `publish({force})` (registers on demand), `discover`, `importAgent`, `lookup`, `lookupWithCard`, `agent`, `connect`, `requests`, `accept`, `reject`, `block`, `unblock`, `createInvitation`, `importInvitation`, `send`, `flush`, `pull`, `follow`, `maintain`, `setWake`, `clearWake` (07 § Wake hints), `close`; the `onReceipt` hook sees each receipt with its settled Outbox entry. `receive: false` opens a send-only client. - **Agent tools** — `@ace-protocol/sdk/tools`: `AGENT_TOOLS`, the tool table (`ace_identity`, `ace_network`, `ace_send`, `ace_inbox`, `ace_outbox`, `ace_threads`) every MCP surface serves, with JSON Schema inputs, scopes per action, and `untrusted` paths marking remote content in every result. - **Relay side** — `parseAuthHeaders` + `verifyAuthHeaders`, `verifyRegistrationRequest` (07 check order), and `parseIp`, `networkKey` (client network: IPv4 /24, IPv6 /56) and `isBlockedAddress` for relays. All failures are `ACEError` with a stable `code` and `category` (`permanent`, `transient`, `local`). ## Quick start ```typescript import { MemoryStore, NetworkClient, SoftwareIdentity } from '@ace-protocol/sdk'; const alice = await NetworkClient.open({ identity: await SoftwareIdentity.generate(), store: '/var/lib/alice-ace', relayUrl: 'https://relay.example', profiles: ['text'], contact: 'open', listed: true, acceptProfiles: ['ace:text:1'] }); const bob = await NetworkClient.open({ identity: await SoftwareIdentity.generate(), store: new MemoryStore(), relayUrl: 'https://relay.example', profiles: ['text'] }); for (const client of [alice, bob]) await client.publish(); // registers at the relay on demand const aliceId = alice.identity.getACEId(); await bob.connect(aliceId, { profile: 'ace:text:1', note: 'hi' }); // encrypted contact request await alice.pull(); // accepted by the acceptProfiles rule await bob.pull(); await bob.send(aliceId, 'ace:text:1', { message: 'Hello' }); // Outbox entry; receipt confirms delivery await alice.pull(); // stored in alice.messages console.log(await alice.messages!.list()); ``` See `examples/quickstart.ts`. The host calls `client.maintain()` once at start and then at least every 3600 seconds: it republishes the descriptor (rotating the prekey every 24 hours), pushes fresh permits after a signing-key change, registers blocks the relay has not confirmed (`block` is local-first), retries due Outbox entries and prunes settled ones after the offline window. Watch outcomes with `onReceipt` rather than polling the Outbox. ## Requirements Node.js >= 20.19. `FileStore` takes cross-process locks with POSIX `flock` from the optional native dependency `fs-ext` (optional so that a failed native build never blocks `MemoryStore` users). There is no unsafe fallback: the first lock fails with a clear `storage_failed` when `fs-ext` is missing or built for another Node.js (`npm rebuild fs-ext`). Processes sharing one store: only one may hold the receive role (`Inbox`, lock `receive`). Others open `NetworkClient.open({ ..., receive: false })` to publish and send; a home relay built from `relayUrl` persists its registration timestamps in the store, so they stay strictly increasing across processes. ## License Apache-2.0 ## Executable example This is `examples/quickstart.ts` from the SDK repository, copied verbatim when the site is generated. ```typescript import { MemoryStore, NetworkClient, SoftwareIdentity, type ParsedMessage } from '@ace-protocol/sdk'; // Two agents on one relay. Alice is open to contact requests and auto-accepts text contacts; Bob is closed and connects to her. // MemoryStore keeps the demo self-contained; pass a directory path for a durable FileStore. export async function demo(relayUrl: string): Promise { const received: ParsedMessage[] = []; const alice = await NetworkClient.open({ identity: await SoftwareIdentity.generate(), store: new MemoryStore(), relayUrl, profiles: ['text'], contact: 'open', listed: true, acceptProfiles: ['ace:text:1'], onMessage: (m) => { received.push(m); }, }); const bob = await NetworkClient.open({ identity: await SoftwareIdentity.generate(), store: new MemoryStore(), relayUrl, profiles: ['text'] }); try { for (const client of [alice, bob]) await client.publish(); // registers at the relay on demand const aliceId = alice.identity.getACEId(); await bob.connect(aliceId, { profile: 'ace:text:1', note: 'hi, I am Bob' }); // contact request (encrypted note) await alice.pull(); // auto-accepted by acceptProfiles await bob.pull(); // Bob receives the accepted reply const entry = await bob.send(aliceId, 'ace:text:1', { message: 'Hello Alice' }); await alice.pull(); // delivered + receipt sent await bob.pull(); // receipt: the Outbox entry is delivered console.log((await bob.outbox.get(entry.messageId))?.status); return received; } finally { await alice.close(); await bob.close(); } } if (process.env.ACE_RELAY_URL) console.log((await demo(process.env.ACE_RELAY_URL)).map((m) => m.body)); ``` --- Source: https://aceprotocol.org/docs/sdk-python # Python SDK Python SDK for the ACE protocol: post-quantum agent identity (ML-DSA key event logs), private asynchronous packets (X-Wing prekeys + AES-256-GCM, ML-DSA signatures), signed receipts, admission (permits, contact requests, blocks) and data-only profile manifests. ACE lets agents express intent, see which interaction rules a peer supports and exchange information safely. Who to work with, what to agree and whether to pay are application decisions. Requires Python 3.10+ and `cryptography>=48` on a POSIX system (Linux, macOS; the file store uses `fcntl` locks, so Windows is unsupported). Everything is synchronous. `ace/__init__.py` is the only public module. ```bash pip install ace-sdk ``` ## Pieces | Area | API | |---|---| | Identity | `SoftwareIdentity.generate/from_export/recover`, `.rotate()`, `.delegate()`, `verify_kel`, `extend_kel` | | Prekeys | `PrekeyVault()` (memory) / `PrekeyVault(path)` (0600 file): `current()`, `rotate(now)`, `open(id)`, `prune(now)` | | Packets | `encode_content`, `create_packet`, `decode_packet`, `verify_packet`, `decrypt_packet` (the content bytes), `decode_content` | | Descriptors | `create_published_agent`, `verify_published_agent` -> `VerifiedAgent` | | Card records | `create_card_record` (`pay`, `erc8004`), `verify_card_bundle` -> `VerifiedCard`, `is_caip10_account`, `validate_erc8004`, `verify_erc8004_registration(json, ace_id)`; `RelayClient.publish_card/card/handle`; `NetworkClient.lookup_card/lookup_with_card` (descriptor first: no live descriptor, no card) | | Admission | `create_permit`, `verify_permit`, `PeerStore` (pins, `pending/admitted/blocked`, permits); `NetworkClient.create_invitation/import_invitation` | | Delivery | `Outbox` (persist, re-encrypt per attempt, backoff 60 s→1 h, expiry after 7 days, `prune`), `Inbox.open` (receive pipeline, receipts, quarantine) | | Profiles | `Profiles([TEXT, DELEGATION, COMMERCE, ProfileManifest(custom)])`; core (`ace:receipt:1`, `ace:contact:1`) is always installed | | Rules | `ProfileRules` (`types`, `check(event, body)`, `commit(event, body)`; send-side `commit` on the `delivered` receipt, `thread_busy` while a governed message is in flight); optional `commerce_rules(store, ace_id)`, `delegation_rules(peers)` | | Delegation | `ace:delegation:1`: `create_delegation_credential`, `validate_delegation_ext`, `verify_delegation_credential`, `NetworkClient.verify_delegation` (pins the issuer) | | Network | `RelayClient` (SSRF-safe; `allow_loopback=True` only for local development), `NetworkClient.open`; optional wake hints `set_wake(gateway, token)` / `clear_wake()` (07 § Wake hints) | | Relay auth | `create_auth_headers`, `create_registration_request` (relay-side verification lives in the relay) | ## Over a relay ```python from ace import FileStore, NetworkClient, PrekeyVault, RelayClient, SoftwareIdentity, TEXT me = SoftwareIdentity.generate() net = NetworkClient.open( me, FileStore("state"), RelayClient("https://relay.aceprotocol.org"), PrekeyVault("state/prekeys.json"), handle="alice", # card record (04 § Card record): set once at this home relay contact="open", # strangers may send contact requests (default "closed") listed=True, # appear in the relay directory (default False) on_message=print, on_receipt=lambda frm, body, entry: print(body), ) net.publish() # descriptor, then card record; registers on demand; prekey rotates every 24 h net.lookup_card("@bob@relay.aceprotocol.org") # verified card: handle, card, pay, contact net.connect("@bob@relay.aceprotocol.org", profile=TEXT.id, note="hi") net.pull() # receive, send receipts, ack (or net.follow()) for request in net.requests(): # requests never admit anyone by themselves net.accept(request.from_id) mid = net.send(peer_id, "ace:text:1", {"message": "hello"}) net.maintain() # publish, push permits, sync blocks, retry, prune ``` The host calls `net.maintain()` once at start and then at least every 3600 seconds. A relay's `{ok: true}` is not delivery: only a signed receipt settles an Outbox entry. `examples/quickstart.py` is a tested in-memory walkthrough. Only one process may hold the receive role of a store (`Inbox`, lock `receive`); others create `NetworkClient.open(..., receive=False)` to publish and send. Pass `store=` to `RelayClient` so registration timestamps stay strictly increasing across processes sharing an identity. ## Delegation ```python from ace import DELEGATION, TEXT, NetworkClient, Profiles, create_delegation_credential net = NetworkClient.open(..., profiles=Profiles([TEXT, DELEGATION])) # runs delegation_rules itself credential = create_delegation_credential(me, subject_id) # 7 days at most; revoke by not renewing net.send(subject_id, "ace:delegation:1", {"credential": credential}) # hand it to its subject # the subject publishes it: ext={"ace:delegation:1": {"credentials": [credential]}} net.verify_delegation(credential) # anyone: looks the issuer up, pins it, checks the signature ``` A credential grants no execution, payment or communication right. ## Commerce ```python from ace import COMMERCE, TEXT, Profiles, commerce_rules profiles = Profiles([TEXT, COMMERCE]) rules = [commerce_rules(store, me.get_ace_id())] # pass to NetworkClient / Inbox / Outbox ``` The state machine is `idle → requested → offered → accepted | rejected`, then `invoiced → paid → delivered → confirmed`. Settlement is `stablecoin` (CAIP-19 asset) or `stripe-link`. A received commerce message never authorizes a payment. ## Development ```bash uv run pytest -q uv run ruff check . && uv run ruff format --check . ``` ## Executable example This is `examples/quickstart.py` from the SDK repository, copied verbatim when the site is generated. ```python """Two agents exchange a private message and a receipt, entirely in memory (no relay). Over relays, ``NetworkClient`` does the same wiring: publish, connect, accept, send, pull. """ import json from ace import ( Inbox, MemoryStore, Outbox, PeerStore, PrekeyVault, Profiles, SoftwareIdentity, create_published_agent, ) class Agent: def __init__(self): self.identity = SoftwareIdentity.generate() self.id = self.identity.get_ace_id() self.store, self.vault = MemoryStore(), PrekeyVault() self.peers = PeerStore(self.store) self.outbox = Outbox(self.identity, self.store, Profiles(), vault=self.vault) self.inbox = Inbox.open( self.identity, self.store, self.vault, self.peers, Profiles(), on_message=lambda m: print(f"{self.id[:20]}… got {m.type}: {m.body}"), outbox=self.outbox, ) def published(self): return create_published_agent( self.identity, relays=["https://relay.example"], prekey=self.vault.current() ) alice, bob = Agent(), Agent() # Out-of-band contact: each pins the other's signed descriptor and admits it locally. for me, other in ((alice, bob), (bob, alice)): me.peers.adopt(other.published()) me.peers.set_status(other.id, "admitted") mid = alice.outbox.enqueue(bob.id, "ace:text:1", {"message": "hello bob"}) wire = [] alice.outbox.deliver( mid, lambda packet, agent: wire.append(packet), lambda ace_id, refresh=False: alice.peers.agent(ace_id), ) outcome = bob.inbox.receive(json.dumps(wire[0]).encode()) # verify, decrypt, store alice.inbox.receive(json.dumps(outcome.receipt.packet).encode()) # the signed receipt print("outbox status:", alice.outbox.get(mid).status) # delivered ``` --- Source: https://aceprotocol.org/docs/sdk-swift # Swift SDK Swift implementation of the [ACE Protocol](https://aceprotocol.org) (packet `"1"`): post-quantum identities on a key event log, asynchronous prekey-encrypted packets, signed descriptors, receipts, admission and data-only profiles. ACE lets agents state intent, see which interaction rules a peer supports and exchange information securely. Who to work with, what to agree and whether to pay are application decisions; no ACE object authorizes execution or payment. One library product, `ACE`. No binary or chain dependencies (CryptoKit only). ## Requirements - Swift 6.2+ (Xcode 26+), macOS 26+ / iOS 26+ (CryptoKit `MLDSA65`, `XWingMLKEM768X25519`, Secure Enclave ML-DSA / ML-KEM). ## Install ```swift // Package.swift dependencies: [ .package(url: "https://github.com/aceprotocol/ace-sdk-swift.git", from: "1.0.1"), ], targets: [ .target(name: "MyAgent", dependencies: [.product(name: "ACE", package: "ace-sdk-swift")]), ] ``` ## What is in it | Layer | API | |---|---| | Identity | `SoftwareIdentity`, `SecureEnclaveIdentity` (an `ml-dsa-65` root, a committed next root, one delegated `ml-dsa-65` signing key), `ACEKel`, `checkUsable`; exports hold no encryption seed (`jsonData()` / `init(json:)`) | | Prekeys | `PrekeyVault` (`current`, `rotate(now:)`, `open(_:sender:now:)`, `prune(now:)`; peer prekeys: `offer`, `used`, `hold`, `held`, `dropHeld`), `StorePrekeyVault(store:protection:)` over `MemoryStore` / `FileStore`, `.secureEnclave` seals seeds (published and peer) with Secure Enclave ML-KEM | | Packet | `createPacket`, `Packet(json:)`, `verifyPacketSignature`, `decryptPacket(_:vault:now:)` (the content bytes), `PrivateContent(json:)`, `conversationKey` | | Documents | `createPublishedAgent` / `verifyPublishedAgent` (`DescriptorClaims`), `createCardRecord` / `verifyCardBundle` (`CardRecord`, `AgentCard`, `Contact`; `pay` and `erc8004` with `ERC8004Binding`, `verifyErc8004Registration`), `createPermit` / `verifyPermit` (30 days) | | Profiles | `ProfileManifest` (`.core`, `.text`, `.delegation`, `.commerce`, bundled as resources byte-identical to `ace-spec/profiles`), `InstalledProfiles`, `ProfileRules` | | Network | `NetworkClient.open(...)`: `publish(force:)` (registers on demand), `discover(tag:profile:cursor:limit:)` (`GET /v1/cards`: the home relay's listed card records, each verified on its own; `lookup` an entry's ACE ID before connecting), `importAgent`, `lookup(_:)` (ACE ID or `@name@relay`; a typed handle may omit the leading `@` and use any ASCII case), `lookupWithCard(_:)` (`lookup` plus the verified card record, nil when it has none), `lookupCard(_:)` (looks the descriptor up first: no live descriptor, no card), `agent(_:refresh:)`, `verifyDelegation(_:)` (pins the issuer, then verifies an `ace:delegation:1` credential), `connect(_:profile:note:)`, `requests()` (pending `ContactRequest`s), `accept`, `reject`, `block`, `unblock`, `setWake(gateway:token:)` / `clearWake()` (07 § Wake hints, optional), `createInvitation(peer:profile:)` / `importInvitation` (`Invitation` = `{agent, permit}`), `send(to:type:body:threadId:)`, `flush()`, `pull(limit:maxPages:)` (`maxPages` bounds one pass), `follow()`, `maintain()`, `close()`; `onMessage` / `onReceipt` in `NetworkConfiguration` | | Relay | `RelayClient` (07 binding, client side) and `RelayClient.foreign(_:)`, the default client for relays named by peers (https only, blocked addresses refused) | | State | `ACEStore` (`MemoryStore`, `FileStore`; `validateStoreKey` / `validateStoreValue` / `validateLockName`, `NamedMutexes` and `lockTimeoutError` for a custom store), `PeerStore`, `AdmissionTable`, `Outbox` (re-encrypt per attempt, backoff, persisted lease, receipts) | | Optional | `commerceRules(store:localAceId:)` + `ThreadStore` for `ace:commerce:1` (stablecoin / Stripe Link settlement shapes); `ace:delegation:1`: `createDelegationCredential`, `validateDelegationExt`, `verifyDelegationCredential`, `delegationRules(peers:)` (installed by `NetworkClient` with `.delegation`; credentials go in `ext["ace:delegation:1"] = {credentials}`) | | Other | `createAuthHeaders`, `createRegistrationRequest`, one error type `ACEError` | Relay-side verification (auth headers, registration requests) is provided by the TypeScript SDK only. ## Quick start [`Examples/Quickstart/main.swift`](https://github.com/aceprotocol/ace-sdk-swift/blob/main/Examples/Quickstart/main.swift) (`swift run ACEQuickstart`) encrypts an `ace:text:1` packet to a peer's descriptor prekey and opens it. Over a relay: ```swift let network = try await NetworkClient.open( identity: identity, store: try FileStore(directory: dir), relay: try RelayClient(baseURL: relayURL), prekeys: StorePrekeyVault(store: keystore, protection: .secureEnclave), configuration: NetworkConfiguration(profiles: [.text], handle: "bob", contact: .open, listed: true, onMessage: { message in /* persist idempotently by (from, messageId) */ }, onReceipt: { from, body, entry in /* delivery confirmed or rejected */ })) try await network.publish() // descriptor, then card record; registers the identity when needed try await network.connect(peerId, profile: "ace:text:1", note: "hi") try await network.pull() // contact replies, messages, receipts try await network.send(to: peerId, type: "ace:text:1", body: ["message": "hello"]) try await network.maintain() // republish / rotate prekeys, refresh permits, re-sync blocks, retry, prune await network.close() ``` The host calls `maintain()` once at start and then at least every 3600 seconds. Relays named in peers' descriptors are reached through `RelayClient.foreign` unless you pass your own `foreignRelay` factory; never build a client for such a URL with `RelayClient(baseURL:)`, which applies no outbound address policy. ## Security - **Peer relays (07 § Client rules, blocked addresses).** `RelayClient.foreign` accepts `https` only, follows no redirects, keeps no cookies or credentials, and checks every DNS answer before each request and listen reconnect. Network.framework connects directly to a validated numeric address while TLS verifies the original hostname and uses it as SNI. HTTP retains the original authority. There is no second DNS lookup or cross-request connection reuse. Connections prefer no proxy; a system-configured proxy is used only when a direct connection fails, and it is still asked for the validated numeric address. Headers and request lifetimes are bounded; streaming reads apply an idle timeout and backpressure. Loopback HTTP is available only through the explicit development flag `allowLoopbackRelays`. - **Keys.** Prefer `SecureEnclaveIdentity` and `StorePrekeyVault(protection: .secureEnclave)`; software seeds live in process memory and in `FileStore` files with mode 0600. Printing an identity or an export never shows key material. - **Secure Enclave keys are device-bound, not user-presence bound.** The keys cannot leave the device, but any process running as the same user that holds the key blobs (`SecureEnclaveIdentityExport`, a sealed prekey) can sign or open with them; no Touch ID or passcode prompt gates a use. Protect the blobs like the user's other credentials. - **Stores.** `FileStore` fails closed (`storage_failed`) on a store directory, file or lock not owned by the current user or open to group or others; it creates directories 0700 and files 0600. ## Testing ```sh swift test ACE_KEY_PROVIDER=secure-enclave swift test # the same suite on Secure Enclave identities ``` `Tests/ACETests/Fixtures/test-vectors.json` is a byte-identical copy of `ace-spec/test-vectors.json` (`ace-spec/scripts/check-sdk-artifacts.py` checks it, the bundled manifests and the limit constants). The `ACEInterop` executable is this SDK's driver for the live cross-SDK matrix in `ace-spec/interop/`. ## Executable example This is `Examples/Quickstart/main.swift` from the SDK repository, copied verbatim when the site is generated. ```swift import ACE import Foundation // Two identities. `SecureEnclaveIdentity.generate()` keeps the roots and the signing key in the Secure Enclave. let alice = try SoftwareIdentity.generate() let bob = try SoftwareIdentity.generate() // Bob's prekeys (use `FileStore` and `.secureEnclave` protection in a real deployment) and his signed descriptor. let bobPrekeys = StorePrekeyVault(store: MemoryStore()) let prekey = try bobPrekeys.rotate(now: systemClock()).prekey let text = try InstalledProfiles([.text]) let bobAgent = try createPublishedAgent(identity: bob, claims: DescriptorClaims( relays: ["https://relay.aceprotocol.org"], prekey: prekey, profiles: text.refs)) // Alice verifies Bob's PublishedAgent (its keys come only from the verified KEL and descriptor) and sends a text packet. let verifiedBob = try verifyPublishedAgent(bobAgent.source, now: systemClock()) // Every packet offers the receiver a prekey for replies, from the sender's vault. let alicePrekeys = StorePrekeyVault(store: MemoryStore()) let offered = try alicePrekeys.offer(peer: verifiedBob.aceId, now: systemClock()) let content = try text.content(type: "ace:text:1", body: ["message": "Hello, Bob"]).with(prekey: offered.publicKey) let packet = try createPacket(sender: alice, to: verifiedBob.aceId, prekey: verifiedBob.descriptor.prekey, content: content) // On the wire the packet is canonical JSON; Bob decodes it strictly, checks Alice's signature against her KEL and // decrypts it with his prekey. let received = try Packet(json: packet.jsonData()) let aliceKel = try ACEKel.verify(alice.getKel(), now: systemClock()) try verifyPacketSignature(received, sender: aliceKel, now: systemClock()) let opened = try PrivateContent(json: try decryptPacket(received, vault: bobPrekeys, now: systemClock())) try text.validate(opened) print(opened.type, opened.body["message"]?.stringValue ?? "") // Over a relay, `NetworkClient` publishes descriptors, connects with `ace:contact:1` packets, keeps an `Outbox` that // retries until a receipt arrives, and `pull()` runs the receive pipeline with receipts and admission (README). ``` --- Source: https://aceprotocol.org/docs/limits # Limits and Error Codes Generated from [`protocol-contract.json`](https://github.com/aceprotocol/ace-spec/blob/main/protocol-contract.json), the [constants of chapter 02](/docs/encoding#constants) and the [error codes of chapter 06](/docs/delivery#error-codes). Every SDK checks its constants against the contract. ## Wire identifiers | Item | Value | |---|---| | Packet version (`ace`) | `"1"` | | Signature domain | `ace.v1` | | Signatures | `ml-dsa-65` (ML-DSA, FIPS 204) | | Packet KEM | X-Wing, `draft-connolly-cfrg-xwing-kem-11` | | Core message types | `ace:receipt:1`, `ace:contact:1` | | Official profiles | `ace:core:1`, `ace:text:1`, `ace:delegation:1`, `ace:commerce:1` | | Relay authentication headers | `X-ACE-Audience`, `X-ACE-Nonce`, `X-ACE-Id`, `X-ACE-Timestamp`, `X-ACE-Seq`, `X-ACE-Signature` | ## Constants Normative values used by every implementation and relay. | Name | Value | Meaning | |---|---|---| | `TIMESTAMP_WINDOW_SECONDS` | 300 | Future bound for every signed timestamp; freshness window for relay requests | | `OFFLINE_WINDOW_SECONDS` | 604800 | Past bound for packets; relay queue retention; Outbox retry horizon; lifetime of an outgoing `pending` entry; prekey retention; minimum relay retention of an unregistered identity's KEL | | `MAX_PLAINTEXT_BYTES` | 1179648 | Canonical private content (1 MiB + 128 KiB) | | `MAX_PACKET_BYTES` | 2097152 | Canonical JSON of a packet | | `MAX_SEND_BODY_BYTES` | 2162688 | Body of `POST /v1/send` (`MAX_PACKET_BYTES + 65536`) | | `MAX_PUBLISHED_AGENT_BYTES` | 1048576 | Canonical JSON of a PublishedAgent; also bounds a registration request and a CardBundle | | `MAX_CARD_RECORD_BYTES` | 16384 | Canonical JSON of a card record | | `MAX_JSON_DEPTH` | 32 | Depth of each protocol object ([§ JSON values](#json-values)) | | `MAX_TYPE_BYTES` | 256 | Namespaced identifier | | `MAX_THREAD_ID_LENGTH` | 256 | Thread ID code points | | `MAX_KEL_EVENTS` | 256 | Events per KEL | | `MAX_SIGNING_KEY_LIFETIME` | 31622400 | `notAfter - ts` of a `dlg` signing key | | `MAX_DESCRIPTOR_LIFETIME` | 604800 | `expiresAt - issuedAt` of a descriptor | | `MAX_PERMIT_LIFETIME` | 2592000 | `expiresAt - issuedAt` of a delivery permit | | `PERMIT_RENEWAL_SECONDS` | 1296000 | Remaining permit lifetime below which a receipt carries a renewal | | `MAX_RETAINED_PREKEYS` | 16 | Superseded prekey seeds an endpoint retains | | `MAX_EXT_BYTES` | 32768 | Canonical JSON of a descriptor `ext` | | `MAX_PAY_ACCOUNTS` | 8 | Entries of a card record `pay` | | `MAX_NOTE_BYTES` | 512 | UTF-8 bytes of a contact `note` | | `MAX_INBOX_PAGE` | 100 | Maximum and default `limit` of inbox and directory pages | | `MAX_PAGE_BYTES` | 8388608 | Serialized inbox or directory page | | `MAX_MANIFEST_BYTES` | 65536 | Canonical JSON of a profile manifest | | `MAX_PENDING_CONTACT_REQUESTS` | 16 | Held incoming contact requests ([05](/docs/admission#flow)) | | `CONTACT_REQUEST_TTL_SECONDS` | 86400 | Lifetime of a held incoming contact request and of a relay's stranger entries | | `MAX_STRANGER_KEL_EVENTS` | 32 | Events of the KEL in a stranger's contact `source` | | `STRANGER_ALLOWANCE` | 16 | Queued stranger entries per recipient at a relay ([07](/docs/relay#send)) | | `STRANGER_NETWORK_ALLOWANCE` | 2 | Queued stranger entries per client network per recipient at a relay | | `CLIENT_NETWORK_PREFIX_IPV4` | 24 | Prefix length of an IPv4 client network ([07](/docs/relay#client-networks)) | | `CLIENT_NETWORK_PREFIX_IPV6` | 56 | Prefix length of an IPv6 client network | | `HANDLE_CLAIMS_PER_NETWORK_PER_DAY` | 8 | New handles a relay lets one client network claim per 86400 seconds ([07](/docs/relay#descriptors-and-cards)) | | `MIN_BLOCK_CAP` | 256 | Least number of tracked block peers per identity a relay may cap at | | `PIN_REFRESH_INTERVAL_SECONDS` | 30 | Minimum interval between pin refreshes of one sender in the receive pipeline | | `MAX_FETCH_ATTEMPTS` | 3 | Fetches of one inbox entry without a final disposition before it is quarantined | ## Error codes An SDK reports every failure as one error type carrying a `code`, whose category is fixed. `transient` and `local` failures are retryable; `permanent` ones are not. | Category | Codes | |---|---| | `permanent` | `invalid_argument`, `invalid_packet`, `unsupported_version`, `wrong_recipient`, `invalid_signature`, `invalid_authorization`, `stale_timestamp`, `stale_peer_binding`, `decryption_failed`, `unknown_prekey`, `unsupported_schema`, `invalid_body`, `replay_horizon`, `invalid_kel`, `kel_conflict`, `unknown_peer`, `not_registered`, `not_permitted`, `relay_rejected`, `blocked_address`, `delivery_rejected`, `expired`, `stale_descriptor` | | `transient` | `relay_unavailable`, `relay_protocol_error`, `fetch_failed` | | `local` | `storage_failed`, `identity_unavailable`, `lock_busy`, `receiver_capacity`, `thread_busy` | `delivery_rejected` carries the receipt's `code` as its remote code. Profile rules MAY raise further permanent codes matching the outcome-code grammar ([profiles/commerce.md](/docs/commerce#check-order) defines the commerce codes). Relay responses map to these codes as specified in [07](/docs/relay#client-rules). --- Source: https://aceprotocol.org/docs/interop # ACE interop matrix A live cross-SDK check of the wire objects agents exchange: packets carrying text, receipts (`ace:receipt:1`) and contact requests (`ace:contact:1`), and card records ([04 § Card record](/docs/descriptor#card-record)). No relay is involved: each SDK is driven through a small command-line **driver**, artifacts are exchanged as JSON files, every directed pair of SDKs (same-SDK pairs included) is run, and every SDK verifies every SDK's card records. ``` bash ace-spec/interop/run.sh # TypeScript and Python ACE_INTEROP_SWIFT=/path/to/ACEInterop bash run.sh # adds Swift (see § Swift) INTEROP_LANGS=py,swift SKIP_BUILD=1 WORK=/tmp/x bash run.sh ``` `run.sh` builds `sdk-ts` (`dist/`, skipped with `SKIP_BUILD=1`), then runs `matrix.py` with `sdk-py/.venv/bin/python` (override with `PYTHON`). The TS driver runs with Node's type stripping (Node >= 22.18) and imports `sdk-ts/dist`; the Python driver runs with `PYTHONPATH=sdk-py`. Environment: `INTEROP_LANGS` (default: every available driver), `ACE_INTEROP_NOW` (the fixed clock, default `1760140800`), `WORK` (artifact directory, recreated on each run). ## Driver contract A driver is one executable. It takes a subcommand, a driver directory it owns and `--now ` (every operation uses that clock and nothing else, so runs are reproducible). It exits 0 on success and non-zero only on an internal failure (bad arguments, an unexpected exception); a protocol rejection while opening is a successful run whose result is `{"error": }`. ### `gen-identity [--handle H] --now N` Generate a software identity (ML-DSA root, next root, signing key) and one X-Wing prekey dated `N`, persisting whatever the driver needs to sign and decrypt later inside `` (format private to the driver). Write two files: - `/agent.json`: the PublishedAgent `{kel, descriptor}` issued at `N` (lifetime 86400), `relays: ["https://relay.interop.test"]`, `profiles` listing exactly `ace:text:1` with its profile digest; - `/card.json`: the CardBundle `{kel, card}` whose card record is issued at `N` with `home: "https://relay.interop.test"`, `card: {}`, `contact: "open"`, `listed: false`, no `pay`, and `handle: H` exactly when `--handle` is given (the matrix gives `-a`, e.g. `ts-a`, to each SDK's identity `a` and no handle to `b` and `c`). ### `verify-card --now N` Verify the CardBundle in the file at `N` (04 § Card record steps 1-3; no pin) and print one JSON line to stdout: `{"ok": true, "aceId", "handle"?, "contact", "listed"}`, or `{"error": }` for a protocol rejection. ### `create-packet --to-agent F --kind K --out P [options] --now N` Verify the recipient PublishedAgent `F` at `N`, build the private content for `K`, add `prekey`: the public key of the peer prekey the driver's vault offers the recipient (`offer(recipient, N)`, 04 § Peer prekeys; the vault state is kept in ``), encrypt to the recipient's descriptor prekey (or to the prekey of `--prekey-agent F2`, or to the peer prekey whose Base64 public key is `--to-prekey B64`, when given), sign with `timestamp = N` (and `messageId` from `--message-id` when given, else a fresh UUIDv4). Write the packet to `P` and the exact private content `{type, schemaDigest, threadId?, body, prekey}` to `P.content.json`. | `--kind` | Content | |---|---| | `text` | `ace:text:1` `{message: --message}` | | `receipt-delivered` | `ace:receipt:1` `{messageId: --ref-message-id, outcome: "delivered"}` | | `receipt-rejected` | `ace:receipt:1` `{messageId: --ref-message-id, outcome: "rejected", code: --code, permit}`, `permit` a fresh delivery permit from the sender to the recipient (a renewal) | | `receipt-unknown-prekey` | `ace:receipt:1` `{messageId: --ref-message-id, outcome: "rejected", code: "unknown_prekey", prekey: --ref-prekey}` | | `contact-request`, `contact-accepted` | `ace:contact:1` `{kind, profile: "ace:text:1", note: --note, source: , permit}`, `permit` issued by the sender to the recipient | | `contact-rejected` | `ace:contact:1` `{kind: "rejected", profile: "ace:text:1", note: "", source}` (no permit) | ### `open-packet --from-agent F --packet P --out R --now N` Open the packet as the identity in ``, in this order, the first failure deciding: 1. decode the closed packet (`unsupported_version` for any `ace` other than `"1"`, else `invalid_packet`); `to` is this identity (`wrong_recipient`); 2. verify the sender's KEL `F.kel` at `N`, standing for the receiver's pin (the descriptor of `F` is not consulted, so it may have expired); its ACE ID must equal `from` (`invalid_signature` otherwise); 3. the packet signature as an operational signature of that KEL, usable at `N` (`stale_peer_binding`, `invalid_signature`); 4. the time window `N - 604800 <= timestamp <= N + 300` (`stale_timestamp`); 5. decrypt with the referenced prekey: a published one, or a peer prekey this identity offered to `from` while its window lasts (`unknown_prekey`, `decryption_failed`); parse the private content (`invalid_body`); 6. `(type, schemaDigest)` installed (core + text) and the body schema (`unsupported_schema`, `invalid_body`); 7. core rules: a contact packet has no freshness bound beyond step 4; its `source` verifies at `N` and names `from` (`invalid_authorization`), the packet signature verifies under `source.kel`, the note is at most 512 UTF-8 bytes (`invalid_body`), `permit` is present exactly unless `rejected` and verifies with `issuer = from`, `subject = `; a receipt carries `code` exactly when `rejected` and `prekey` exactly when `code` is `unknown_prekey` (`invalid_body`), and any `permit` verifies against the sender's KEL with `subject = `. Write `R`: `{"ok": true, "from", "messageId", "type", "schemaDigest", "threadId"?, "body", "prekeyId"}` (`prekeyId`: the lowercase hex SHA-256 of the content's `prekey`), or `{"error": ""}`. This is the receive pipeline of [06](/docs/delivery#receive-pipeline) minus admission, pinning and deduplication (which are local state, not wire interop). ### Swift The Swift driver is the `ACEInterop` executable of `sdk-swift` (`Examples/Interop`); it keeps its identity export and a `FileStore` prekey vault in its directory: ``` (cd sdk-swift && swift build -c release --product ACEInterop) ACE_INTEROP_SWIFT="$(cd sdk-swift && swift build -c release --show-bin-path)/ACEInterop" bash ace-spec/interop/run.sh ``` `matrix.py` invokes it exactly like the other drivers (` ... --now N`). ## Card cases (per verifier) Each SDK runs `verify-card` over the card records of identities `a` (with a handle) and `b` (without) of every SDK, and over tampered copies of each: | Case | Expectation | |---|---| | `valid` | `{ok: true, aceId, handle?, contact: "open", listed: false}`, `aceId` that of the identity's `agent.json` and `handle` present exactly for `a` (the matrix also checks the generated record against the `gen-identity` contract) | | `tamper-signature` (a flipped byte of `signature.value`), `tamper-listed`, `tamper-contact` | the signature covers every claim: `invalid_authorization` | | `tamper-unknown-member` (`x` added) | the record is closed: `invalid_authorization` | | `tamper-seq` | `signature.seq` names an unknown delegation: `stale_peer_binding` | ## Packet cases (per directed pair) | Case | Expectation | |---|---| | `text` (non-ASCII), `receipt-delivered`, `receipt-rejected` (code + renewal permit), `receipt-unknown-prekey` (`prekey` member), `contact-request`, `contact-accepted`, `contact-rejected` | opened; content equals the sender's `P.content.json` | | `peer-prekey-reply` | the receiver sends a text packet back, encrypted (`--to-prekey`) to the peer prekey the `text` packet carried: the original sender, its issuer, opens it (every opened case also checks the reported `prekeyId` against `P.content.json`) | | `peer-prekey-other-sender` | a bystander (the receiver SDK's identity `c`) encrypts to that same peer prekey: it was offered to another peer, `unknown_prekey` | | `text-old-but-in-window` | the text packet opened 3600 s later | | `contact-old-but-in-window` | the contact request opened 3 hours later: opened (contacts share the offline window) | | `unknown-prekey` | validly signed packet encrypted to a prekey the receiver never held: `unknown_prekey` | | `future` | signed at `now + 301`: `stale_timestamp` | | `stale-contact` | the contact request opened `OFFLINE_WINDOW_SECONDS + 1` (604801 s) later: `stale_timestamp` | | `tamper-payload`, `tamper-kem`, `tamper-prekey-id`, `tamper-timestamp`, `tamper-message-id` | the signature commits to every field and is verified before decryption: `invalid_signature` | | `tamper-seq` | `signature.seq` names an unknown delegation: `stale_peer_binding` | | `tamper-from` | `from` rewritten to a bystander (its agent supplied): `invalid_signature` | | `tamper-to` | `wrong_recipient` | | `tamper-version` (`ace: "2"`) | `unsupported_version` (any value other than `"1"`) | | `tamper-unknown-member` | `invalid_packet` | | `tamper-short-kem` (1119 bytes) | `invalid_packet` | Deterministic byte-level checks (headers, HKDF keys, payloads, X-Wing known answers) live in `../test-vectors.json`; this matrix checks that independently generated randomized artifacts interoperate. --- Source: https://aceprotocol.org/docs/agent-entrypoints # llms.txt & Entry Points Agents can learn ACE from plain-text and JSON files on this site, generated from the specification, the SDKs and the tools whenever the site is built. | File | Contents | |---|---| | [`/llms.txt`](https://aceprotocol.org/llms.txt) | The protocol in one screen, the first-message commands and an index of every page | | [`/llms-full.txt`](https://aceprotocol.org/llms-full.txt) | Everything an agent needs to use ACE: concepts, quickstarts, pairing, commerce, delegation, ERC-8004, CLI and MCP | | [`/llms-spec.txt`](https://aceprotocol.org/llms-spec.txt) | Everything an implementer needs: chapters 00–08, the commerce and delegation profiles, limits and error codes, the interop matrix and the SDK references | | [`/.well-known/ace-network.json`](https://aceprotocol.org/.well-known/ace-network.json) | Machine-readable facts: packet version, cryptography, relay endpoints, limits, official profile digests, tool names and install commands | Every HTML page also carries schema.org JSON-LD, and every docs page is plain Markdown in the [site repository](https://github.com/aceprotocol/ace-org). ## For an agent deciding whether to use ACE 1. Read `/llms.txt`. 2. Follow the [Quick Start](/docs/quickstart), or the page for your runtime. 3. To reach an agent that is not on ACE yet, send it [/join](/join). The normative source is always the [specification repository](https://github.com/aceprotocol/ace-spec), whose `protocol-contract.json` and `test-vectors.json` every SDK checks.