Skip to content

Latest commit

 

History

History
1631 lines (1398 loc) · 102 KB

SPEC.md

File metadata and controls

1631 lines (1398 loc) · 102 KB

0G Private Computer — Protocol Spec

Normative wire spec for the 0G Private Computer end-to-end-encrypted (E2EE) inference protocol — confidentiality (field-level request/response sealing) and authenticity (attestation binding + response-signature verification). Every implementation (Go reference here, future TS/WASM, the broker, the router) MUST agree on it. Keywords MUST / SHOULD / MAY per RFC 2119.

Status: draft. This cut covers the router path: provider discovery + attestation binding, field-level request sealing (E2E confidentiality of the sensitive fields), response sealing, and response-signature verification, for the chat, image, anthropic, speech, embedding and async-image request profiles (§5.1). Candidate scoring is the router's own internal concern (surfaced through its candidate API), not part of this protocol.

The multipart endpoints reach this protocol by being JSON-ified before sealing (§5.3): their request has no top-level JSON object of its own, so the §5.2 AAD rule (JCS(envelope)) and the §8 request binding have no defined input, and §5.3 supplies one rather than canonicalizing multipart. Of the two, speech-to-text is covered (profile speech, §5.3.2, §7.3); image-editing is not — the same mechanism fits it and only its field table is missing.

Until a multipart endpoint has a profile, an implementation MUST reject a sealed request on it rather than forward it, and on every multipart endpoint — covered or not — a body that cannot be parsed as an envelope MUST NOT be treated as "not sealed" and passed through in the clear (§5.3.1).

1. Scope

v1 targets the router path. The request stays a normal (OpenAI-shaped) JSON: the client encrypts only the sensitive fields (the prompt, tool definitions) into a self-contained _e2ee object and leaves the rest — model, sampling params, stream, etc. — as cleartext so the router can route on them. The router selects a provider, forwards the JSON, and the broker decrypts the sealed fields inside the TEE, merges them back, and runs inference. The response is sealed back to a client ephemeral key and carries the existing TEE signature.

Why field-level, not whole-body. The router routes on many request parameters, not just model, and must reach a provider that supports them. Leaving non-sensitive params cleartext lets the router read them directly, so a newly added non-sensitive parameter needs no client change — the client passes it through untouched and the router/broker handle it server-side. The trade-off (accepted): a future field that is sensitive-by-nature is cleartext until the client's sealed-field set is updated (§5.1). Everything cleartext is still integrity-protected (§5.2), so the router can read but not tamper.

The direct path (no router) is the degenerate case: the client already knows the provider, so discovery collapses to a single quote fetch. Everything else is identical. Sealing is required on the router path and optional on the direct path.

2. Terminology

  • Enclave — the attested TEE (Intel TDX / Phala CVM) the provider runs in.
  • Router — the L7 party that ranks providers and forwards the request. It reads the cleartext fields, never the sealed ones.
  • Sealed field — a top-level request field whose value is encrypted into _e2ee.ciphertext (e.g. messages, tools). §5.1.
  • Cleartext field — every other top-level request field, left readable for routing but integrity-protected (§5.2).
  • Candidate — a provider the router offers, with its attestation quote and on-chain identity.
  • Signer key — the provider's ECDSA (secp256k1) key; its address is the on-chain teeSignerAddress. Signs responses. (Existing.)
  • Enc key — the provider's X25519 key used as the HPKE recipient. New.
  • Quote — the TDX attestation, carrying 64 bytes of report_data.
  • JCS — JSON Canonicalization Scheme, RFC 8785. Used to get deterministic bytes for the AAD and for content hashing.

3. Crypto suite (v1)

HPKE per RFC 9180, single ciphersuite in v1:

Role Algorithm ID
KEM DHKEM(X25519, HKDF-SHA256) 0x0020
KDF HKDF-SHA256 0x0001
AEAD ChaCha20Poly1305 0x0003
  • HPKE mode: mode_base (0x00) for the request and, by default, the response. A response to a request carrying the response binding uses mode_psk (0x01), keyed by a value only the sender and the enclave hold (§7.6). No other mode is used.
  • Request confidentiality: HPKE Seal of the sealed-field object to the provider enc key; cleartext fields are bound as AAD (§5, §6).
  • Response confidentiality: a fresh HPKE Seal from the enclave to the client's ephemeral X25519 key carried in _e2ee (§7). Independent of the request context.
  • Signatures: ECDSA secp256k1 over an EIP-191 personal_sign digest (unchanged from the broker's current scheme). See §8.
  • Determinism: all AAD and content hashes are taken over JCS-canonical JSON, so Go/TS/Rust agree byte-for-byte.
  • Hashes: SHA-256 unless stated. Binary fields on the wire are base64url (no padding).

4. Provider enc key & attestation binding

4.1 Derivation

The enclave MUST derive the X25519 enc key inside the TEE, from a key derivation path distinct from the signer key (e.g. dstack DeriveKey("enc")). The private key MUST NOT leave the enclave. (Signer and enc are two separate keys: the signer is the stable on-chain identity; the enc key can be rotated independently for prompt forward-secrecy. See the design doc.)

4.2 report_data layout (64 bytes)

The quote's report_data MUST be exactly:

offset  size  field
0       32    enc_pub        X25519 public key (RFC 7748 u-coordinate, little-endian)
32      20    signer_addr    secp256k1 Ethereum address (20 bytes)
52      4     version        uint32, big-endian; = 1 for this spec
56      8     reserved       MUST be zero

This binds both keys into the same attestation, and lets a verifier extract enc_pub directly from a verified quote — no side channel.

Migration note: the broker currently writes the signer address (hex) into report_data. Adopting this layout is a breaking change gated by version.

4.3 Key id

key_id = SHA-256(enc_pub)[0:8] (8 bytes, base64url on the wire). Lets the enclave select the right key across rotations.

4.4 Provider discovery, pin & fallback (router path)

The router ranks candidates on its live fleet view; the client pins one and does its own fallback loop. The router honors the pin and forwards the JSON opaquely — it does not re-route or decrypt. (Phase i-a of ../docs/design/router-e2e.md.)

Control plane (discovery). The client calls the router's candidate API (model

  • constraints — no body). The router returns an ordered candidate list; for each, the provider's attestation quote and on-chain teeSignerAddress. The router only transports the quote; the client verifies it independently, so a router that returns a bogus or swapped quote is caught, not trusted.

Client obligations before sealing. For the candidate it pins, the client MUST:

  1. Verify the quote — genuine TDX + expected measurement (trust model in ../docs/design/router-e2e.md).
  2. Extract enc_pub + signer_addr from report_data, check version.
  3. Confirm signer_addr equals the provider's on-chain teeSignerAddress.

Only then is enc_pub trusted as the HPKE recipient. The client seals (§6) and sets _e2ee.signer_addr (the pin) and a fresh ephemeral key for the response (§7).

Data plane. The client sends the JSON to the router; the router reads the cleartext fields, re-authenticates as itself (its own billing account), honors the pin, and forwards to the pinned provider without re-routing.

Fallback is client-side. If the pinned provider fails, the client pins the next candidate, re-seals to its enc_pub, and retries. Verification is fail-closed: a candidate that fails quote verification is skipped, never sealed to.

The router cannot substitute its own key: it can only offer candidates whose quotes bind an on-chain teeSignerAddress, which it cannot forge.

5. Request envelope (v1 wire format)

The request is the original OpenAI JSON with the sealed fields removed and an _e2ee object added. Example (client sealed messages and tools):

{
  "model": "gpt-4o",
  "temperature": 0.7,
  "max_tokens": 1024,
  "stream": true,
  "_e2ee": {
    "v": 1,
    "kem_id": "0x0020",
    "key_id": "<base64url, 8 bytes>",
    "signer_addr": "0x<40 hex>",
    "client_eph_pub": "<base64url, 32 bytes>",
    "enc": "<base64url, 32 bytes: HPKE encapsulated key>",
    "sealed_fields": ["messages", "tools"],
    "ciphertext": "<base64url: HPKE seal output over the sealed-field object>"
  }
}
  • Every original top-level field not in sealed_fields stays cleartext.
  • client_eph_pub is where the enclave seals the response (§7). It lives in the AAD-protected _e2ee, so the router cannot swap it (that would break Open).
  • signer_addr is the pinned provider's TEE signer address (§4.4); the enclave rejects a request whose signer_addr != its own teeSignerAddress.
  • unbound_fields (optional, omitted when empty) lists cleartext fields excluded from the AAD — intermediary-mutable metadata; see §5.2.

5.1 Sealed-field set

  • Sealed plaintext = a JSON object holding exactly the sealed fields with their original values, serialized as JSON. Canonicalization is not required here: the AEAD binds the exact ciphertext bytes and the §8 signature binds the ciphertext, so the pre-encryption byte layout is irrelevant. Example: {"messages": <original>, "tools": <original>}.
  • v1 default sealed set: messages, tools and tool_choice — which for this profile is exactly its payload fields, messages always and the other two whenever the request carries them. The default and the requirement are the same list by construction, and that is the point: a default that omitted a payload field would produce, on a conforming client, an envelope the rules refuse.
  • The payload field is REQUIRED, not recommended. A sealed envelope whose sealed_fields omits it MUST be refused, by the sender at seal time and by the enclave at open time — before any decryption, since the check reads only sealed_fields (§12, first row). Leaving messages cleartext exposes the prompt, which defeats the purpose of sealing at all, so this is not a deployment policy a broker may decline: it is what makes a "sealed request" mean anything. What remains a deployment choice is the separate question of whether a broker accepts UNSEALED requests on the same endpoint.
  • tools was a mere default in earlier v1 implementations and is now a conditionally required payload field (below). A tool schema names an operation the calling application performs — transfer_funds, get_patient_record — so a schema list describes what the user is doing, in the caller's own vocabulary, as directly as a prompt does. As a default it was droppable: a sealed_fields of just ["messages"] satisfied every rule in this document and handed the schemas to the router.
  • tool_choice is payload for tools' reason, carried further. Its object form names ONE operation — {"type":"function","function":{"name":"…"}} on chat, {"type":"tool","name":"…"} on /v1/messages — so it is the operation being INVOKED where the schema list is the menu of available ones, which is the more concentrated disclosure of the two. Sealing every schema while leaving this readable would hide the catalogue and hand over the selection. Its enum forms ("auto", "none", "required") carry nothing and are sealed anyway, per the literal-presence rule below: the requirement is about the field, not its value. An INTERMEDIARY may no longer range-check it — its value is ciphertext to everything without the key — so a malformed tool_choice is refused by the upstream rather than at the router, the same trade tools already makes. The enclave is unaffected: it holds the key, and OpenRequestFor hands it the reconstructed request.
  • A client MAY seal additional fields (e.g. metadata, user); it declares them in sealed_fields.
  • Applying the default to a request. A profile's default contains every payload field it has, optional ones included, so an ordinary request will not carry all of them — a chat request with no tool_choice, an Anthropic one with no system. A sender applying the default MUST therefore narrow it to the fields the request actually carries, and sealed_fields names the narrowed set. This cannot widen what leaks: narrowing only ever drops a field the request does not have, and an absent field cannot ride in the cleartext half. The rules above still apply to the result, so a request carrying no payload field at all is still refused. (The reference library does this for a nil sealed set; a caller passing an explicit set is stating exactly what to seal and it is used verbatim.)
  • New / unknown fields default to cleartext. A field only becomes sealed when a client version adds it to its sealed set. (Accepted trade-off, §1.)
  • After Open, the enclave MUST verify the decrypted object's keys exactly equal sealed_fields, and that no sealed field name also appears as a cleartext top-level field (collision → reject). It reconstructs the original request = cleartext fields (minus _e2ee) merged with the decrypted fields.

Request profiles

Different endpoints carry their sensitive payload in different fields. A request profile names one such request family and fixes its payload field — the field a sealed envelope of that family MUST cover — plus the v1 defaults. (Distinct from the signature profile of §8/§9, which versions the signed-text format.)

Profile Endpoint Payload field (required) Pinned cleartext field Default request sealed set Default response sealed set
chat /v1/chat/completions messages, and tools / tool_choice whenever present — messages, tools, tool_choice choices
image /v1/images/generations prompt response_format = b64_json (§7.1) prompt data
anthropic /v1/messages messages, and system / tools / tool_choice whenever present — messages, system, tools, tool_choice per frame shape (§7.2)
speech /v1/audio/transcriptions (JSON-ified, §5.3) file_base64, and filename / language / prompt whenever present response_format ∈ {json, verbose_json} (§5.3.2); stream = false when present (§5.3.3) file_base64, filename, language, prompt text, and segments / words / language whenever present (§7.3)
embedding /v1/embeddings input — input data (§7.4)
async-image /v1/async/images/generations (result returned by GET /v1/async/jobs/{id}) prompt and _bind response_format = b64_json (§7.1) prompt, _bind data, _bind (§7.5)

A pinned cleartext field is one that stays readable but may hold only a permitted value in a sealed request — one value, or a small set of them (image: exactly b64_json; speech: either of json / verbose_json). The field must still be PRESENT: what a pin guards against is the server's own default, which an absent field selects, so a set of two permitted values is not a licence to omit the field. Sealing the payload does not cover any of this, and two distinct reasons put a field in this category:

  • Its other values direct the server to publish the result outside the sealed channel — the image profile's response_format, where url has the enclave serve the generated images from a plain URL. See §7.1, including why the field is required rather than defaulted.
  • Its other values select a response this protocol cannot express — the speech profile's response_format, where text / srt / vtt return a body that is not a JSON object, so there is no frame to seal and no aad for the §8 binding. See §5.3.2.

The first is a leak, the second is an impossibility, and they are worth telling apart when adding a profile: the first argues for requiring the field (silence means the leak), the second does not (silence may already be the safe value) and argues instead for a legible refusal.

A field that resembles both and is neither must NOT be pinned. The test is what the other values do, not what the field is called, and the embedding profile is the case that makes the distinction load-bearing: /v1/embeddings carries encoding_format (float / base64), which reads exactly like the image profile's pinned response_format. It is not the same kind of field. Image's url sends the result outside the sealed channel; both of encoding_format's values put the vectors in data, which the profile seals either way. It selects an encoding, not a destination — so there is nothing for a pin to protect. dimensions is the same: it truncates the vector, and the vector is sealed at any length.

Pinning either would also refuse conforming traffic, and note which half of that does the work: a pin requires the field to be present (silence selects the server's default, which is the thing a pin guards against), so pinning encoding_format would reject every request that simply omits it — the ordinary case on an endpoint where the default is fine. OpenAI's official Python client, which requests base64 explicitly for wire efficiency, would be rejected too, on the value rather than the absence.

A profile whose cleartext fields are merely metadata about the request pins nothing, and embedding is the table's example of one. user, where a caller sends it, is likewise cleartext — the default above for a field no profile names — and a caller putting a real end-user identifier in it SHOULD add it to its own sealed_fields, which §5.1 permits.

A conditionally pinned cleartext field is the optional-presence sibling: the field may be absent — the endpoint's default is what the profile wants — but when present it must hold a permitted value. See §5.3.3 for the case (stream on the speech profile), for why reusing the unconditional machinery rejects every conforming request, and for why the rule must be a permitted SET rather than a list of refused values on an endpoint whose request is materialized back into multipart.

A conditionally required payload field is one that need not exist, but MUST be sealed whenever the request carries it. Three of the five profiles use it: tools and tool_choice on chat and /v1/messages, system on /v1/messages, and speech's filename / language / prompt (§5.3.2). image and embedding have none: their payload is one mandatory field and nothing else (prompt, input), and that is worth stating rather than generalising over — the category is what a profile needs when payload is OPTIONAL, not a box every profile has to fill.

It is the common shape of "optional but still payload", and reaching for a mere default instead is the mistake it exists to prevent — a default is droppable, so the field rides in the cleartext half with every unconditional check passing. tools is the case that made the point twice: it spent v1 as a default precisely because it is optional, which is the reason it needed this category rather than an argument against it.

Presence is LITERAL, deliberately: a field that appears in the request object is payload, whatever its value, so "tools": [] and "system": null must be sealed exactly like a populated one. That errs toward sealing, which is the safe direction, and it keeps the rule out of reach of a sender who would otherwise aim at a value-shaped exemption. The cost is real and belongs on record: an empty array leaks nothing, so a sender that leaves "tools": [] in the cleartext is refused for a request that discloses nothing — and SDKs that emit an empty array rather than omitting the field are a normal population, not a malformed one. A deployment tightening this rule should therefore expect the refusals to reach further than "senders that actually leak schemas".

Note the consequence for the DEFAULT sealed set, which is what keeps the two from disagreeing: a profile's default MUST contain every payload field it has, optional ones included. A default that omitted one would produce, on a conforming client, exactly the envelope this rule refuses.

tool_choice was that residual in an earlier version of this document and is now payload (above). Closing it required a change OUTSIDE this protocol, which is worth recording because the next such field will need the same: an intermediary that matches provider capabilities reads the PRESENCE of these fields, and sealing one removes it from everything that intermediary can see. A sender that seals such a field must therefore signal its presence by another route — in the 0G gateway, a value-free placeholder on the routing preview — or a capability guarantee the caller asked for is silently weakened.

system is the clearest case: Anthropic puts the system prompt at the top level rather than as a message with role: "system", so it is payload of exactly the same kind as messages while being optional. Neither of the other two categories covers it — required-always rejects the (common) request that omits it, and a mere default is droppable — and getting it wrong is silent: the request seals the conversation, passes every unconditional check, and hands the system prompt to the router in the clear. Because the requirement depends on the request rather than on the set of field names, it is checked where the request is in hand: by the sender before sealing, and by the enclave on the received envelope, where a field that was sealed is already gone and one still present therefore arrived in the clear.

The same category exists on the response side, with the direction of the receiver-side check reversed — the client is then the half that holds. Two profiles use it: Anthropic's stop_sequence (§7.2 rule 6) and speech's segments / words / language (§7.3). The predicate is identical in all four places, which is the point: present in the received cleartext means never sealed.

Whatever a profile seals, a response frame MUST leave usage and model cleartext: the router reads them without a key to bill and attribute, so sealing one makes the response unbillable rather than merely private.

Cleartext is only half of it — usage MUST also stay BOUND, and a pinned cleartext field MUST NOT be declared unbound either. An unbound field is excluded from the AAD, so an intermediary may rewrite it, Open still succeeds, and — because the §8 binding hashes that same AAD — respH/reqH come out byte-identical. Listing usage in unbound_fields would therefore let a router restate the billable count with nothing detecting it, and listing a pinned field there would let one flip response_format to url in transit and hand the enclave a request that publishes the images in the clear. unbound_fields is the one construct that can silently undo every other guarantee in this document, so the fields whose value must be trusted are excluded from it by rule, not left to the §8.2 corollary:

Field May be sealed? May be unbound?
usage (response) no — the router bills on it no — its value must be authenticated
a pinned cleartext field (§5.1) no — sealing it removes it from the cleartext the server reads, which then falls back to its own default no — the pin would hold only at seal time
the top-level field CONTAINING a billable value, when a profile nests one — Anthropic's message (§7.2 rule 4) no — the router bills on what is inside it no — same reason as usage, one level down
any field a profile's required cleartext quantity is LOCATED in — speech's usage and its top-level duration (§7.3) no — refused explicitly, because with ALTERNATIVE locators it is not otherwise fail-closed: a frame may seal duration and still satisfy §7.3 through a cleartext usage.seconds, leaving the sealed value invisible and the agreement rule uncheckable by the receiver (see below) no — same reason as usage, one level UP. The usage row above is a NAME, so it says nothing about a quantity that is not nested in it
model no — the router attributes on it yes — the router rewrites the alias back; the resulting value is not authenticated (a known trade-off, see §9 and DefaultUnboundFields)
x_0g_trace n/a — router-injected yes — nothing may trust it (§8.2)

The rows above are field NAMES, and that is a trap for a profile that nests. unbound_fields names top-level fields, so a rule written as "usage must stay bound" protects a top-level usage and nothing else: a profile carrying its billable value at message.usage.input_tokens satisfies that rule while leaving the value rewritable, because the name in the denylist and the name on the wire are different. A profile that nests a value whose value must be trusted MUST therefore name the top-level field that contains it (§7.2 rule 4 does), and any new profile MUST answer this question explicitly rather than inheriting the general rule and assuming it reaches.

The trap runs in both directions, and the second one is easy to miss because the row that fails is the one that looks most obviously right. "usage must stay bound" also says nothing about a quantity that is not nested in usage at all: the speech profile's verbose_json shape reports its billable duration as a top-level duration, so a frame declaring duration unbound satisfies every rule in the table above while handing an intermediary the number the router bills on — with Open succeeding and the §8 binding, which hashes the same AAD, coming out byte-identical. This is not hypothetical: the first implementation of the speech profile passed every test in this document and left exactly that hole, because each rule it implemented was the rule as written.

An implementation SHOULD therefore derive the bound requirement from the profile's declared quantity locators, not from a list of names — then a new profile billing on a differently-named field is covered without anyone remembering to extend the list. The reference implementation does this (validateResponseUnboundFieldsFor walks requiredResponseCleartext).

A locator MUST NOT be sealed either, and with alternatives that does not follow from anything else. With a single locator it would: sealing it removes it from the cleartext, so the §7.3 presence check refuses the frame. With two, a frame can seal duration and satisfy the requirement through a cleartext usage.seconds — the presence check passes and the sealed value is simply invisible. What that costs is §7.3's agreement rule: at seal time both values are in hand and a disagreement is caught, but at open time the sealed one is gone from the cleartext, so a client has nothing to compare and a non-conforming enclave could seal a duration that contradicts the number the router bills on. Refusing the seal is what keeps both locators visible to the receiver, and therefore what makes the client's half of the agreement rule exist at all.

Both ends enforce this, and for the "may be unbound" column the RECEIVER is the end that matters. Checking only at seal time stops a conforming implementation from misconfiguring itself; it does nothing about the case the column exists for — a counterparty that declares the field unbound on purpose so an intermediary can rewrite it while Open and the §8 verification both still pass. Concretely:

  • A client MUST reject a response frame whose unbound_fields names a field from the "no" column above — on every frame, since a sealer that varies the set could otherwise declare it only late in a stream.
  • An enclave MUST reject a request whose pinned cleartext field is missing, wrong-valued, sealed, or declared unbound. It cannot delegate this to the client: a third-party client is under no obligation to run the check.

A request profile is not carried on the wire and is not a version: the envelope format, crypto suite, AAD rule and §8 binding are identical across profiles, and sealed_fields is already self-describing, so the enclave's §6 Open check (decrypted keys == declared sealed_fields) needs no profile knowledge. Adding a profile is therefore additive (§9), not a v bump.

The guard has two independent halves, and both SHOULD be implemented:

  • Client side — the only half that can stop a leak before it is sent: a client MUST NOT build an envelope whose sealed_fields omits the profile's payload field. (The reference library refuses to.)
  • Enclave side — the half that does not depend on the sender's goodwill: an enclave serving a known endpoint SHOULD reject a sealed request whose sealed_fields omits that endpoint's payload field, since a third-party client is under no obligation to use the reference library. This is the same deployment policy as the messages one above, and it is what makes the requirement enforceable rather than merely advisory.

5.2 AAD (integrity of the cleartext)

Cleartext fields are authenticated, not encrypted, so the router can read but not tamper (e.g. downgrade model, inflate max_tokens, flip sealed_fields).

aad = JCS( envelope_json with _e2ee.ciphertext AND every field named in
           _e2ee.unbound_fields removed )

i.e. canonicalize the entire transmitted object minus the ciphertext value and minus the intermediary-mutable fields. This binds every remaining cleartext field and every _e2ee metadata field. The enclave recomputes aad the same way over what it received; any tampered bound byte makes Open fail-closed.

unbound_fields is a denylist (default: empty = bind everything) of cleartext fields an intermediary may add/modify/remove:

  • The list itself stays in _e2ee and is therefore bound — an attacker cannot enlarge it (that changes the AAD and Open fails), so it cannot free a field the client bound.

  • It MUST be a JSON array of strings; any other type (or a non-array) is rejected before unsealing. Absent/null means exclude nothing.

  • It MUST be disjoint from sealed_fields and MUST NOT name _e2ee.

  • Values in unbound fields are unauthenticated: nothing may trust them (see §8 — the signature covers only non-unbound content).

  • HPKE info MUST be "0g-pc/v1/seal" (ASCII), domain-separating this usage.

5.3 Multipart endpoints (JSON-ified requests)

Some endpoints in the OpenAI surface carry their payload as multipart/form-data rather than JSON — /v1/audio/transcriptions and /v1/images/edits. Everything in this document above assumes the request is a top-level JSON object: §5.2's AAD is JCS(envelope) and §8's reqH hashes that AAD, so on a multipart body there is nothing to canonicalize and no defined binding input. That is why those endpoints were excluded rather than merely unimplemented.

The rule: the request is converted to JSON before it is sealed, and back to multipart inside the enclave. A JSON-ified request is a top-level JSON object whose fields are the endpoint's form fields, with each binary part carried as a base64 string in a named field. From §5.2's point of view it is an ordinary request, so the AAD rule, the §6 seal/open steps, the §8 binding and the whole §12 table apply unchanged; nothing in the crypto or the envelope is multipart-aware, and this is not a version bump (§9) — it is a profile.

caller ──multipart──▶ sender ──JSON envelope──▶ router ──▶ enclave ──multipart──▶ upstream
                      seals here                opaque       opens here, re-materializes

Two properties make this the cheap direction rather than a compromise:

  • The router never sees multipart on a sealed request, so its multipart handling (form-field model extraction, boundary-preserving rewrites) is not on the sealed path at all.
  • A multipart part header is not a place to hide anything. filename travels as a part header in the clear today; JSON-ifying it makes it an ordinary field that a profile can put in its sealed set. Filenames are payload (board-meeting-2026Q3.m4a), so this is a leak the conversion closes rather than a cost it imposes.

The enclave re-materializes multipart for the upstream because the upstream speaks only multipart. It MUST generate its own boundary rather than carry one from the request (no boundary crosses the sealed channel), and it SHOULD forward the sealed filename — it is inside the TEE by then, and some backends sniff the audio container from the extension.

Base64 encoding of a binary field is standard base64, RFC 4648 §4, padding required — not the base64url-without-padding of §3. §3 governs binary fields that appear on the wire in the clear (enc, key_id, ciphertext), where URL-safety and terseness matter; a binary payload field rides inside the ciphertext, and its encoding is fixed here by a different constraint: the same field name already has an unsealed contract on the router's JSON surface, and one field name must not have two decoders depending on whether the request was sealed.

Size. Base64 costs +33% and the whole body must be buffered before sealing, so an implementation MUST bound the JSON-ified request. A future revision MAY add a detached form (the envelope stays JSON; the large ciphertext travels as a second part, bound by a hash inside the AAD-covered _e2ee) for payloads where that cost matters. It is deliberately not in v1: it is additive, and the sizes seen in practice do not need it.

5.3.1 Content-Type on a multipart endpoint (fail-closed both ways)

A multipart endpoint now accepts two content types, which makes "is this sealed?" a question the receiver MUST answer explicitly rather than by falling through. Both halves are the receiver's and both are fail-closed:

  • A request whose Content-Type is JSON MUST be a valid sealed envelope, or be rejected. It MUST NOT be forwarded as an unsealed JSON request "just in case".
  • A request whose Content-Type is multipart MUST NOT contain a part named _e2ee. One that does MUST be rejected, never forwarded.

The second rule is the one that is easy to omit and expensive to omit. A sealed envelope smuggled into a multipart part is not parseable as an envelope, and the natural implementation of "detect _e2ee, else pass through" — parse the body as JSON, treat a parse failure as not sealed — forwards it in the clear. A body that cannot be parsed as an envelope is not thereby an unsealed body. This holds on every multipart endpoint, including those with no profile yet.

5.3.2 The speech profile

/v1/audio/transcriptions, JSON-ified. Fields, and where each must travel:

Field Kind Must be sealed Why
file_base64 base64 audio yes — payload field, always The audio. Voice is biometric, so this is the profile's whole point; a sealed set that omits it MUST be refused (§12)
filename string yes — whenever present Payload. As a multipart part header it is readable by every intermediary today, so JSON-ifying is what makes sealing it possible — and a mere default would leave "possible" as far as it got
language string yes — whenever present The caller's language hint. The weakest of the three (one of a hundred codes) and required anyway: it is still information about what was said, the router does not route on it, and sealing it costs nothing
prompt string yes — whenever present A biasing text the caller writes — payload of the same kind as a chat prompt
model string no The router routes and attributes on it (§5.1)
response_format string no — pinned to {json, verbose_json} Below
timestamp_granularities array no A knob selecting whether the response carries words[]; not content itself. What it turns on IS content, and §7.3 seals that
stream bool no — pinned to false whenever present §5.3.3
temperature, and any field a future upstream adds — no (§5.1 default) Not content

The three "whenever present" rows are conditionally required payload fields (§5.1), not defaults, and the distinction is the whole difference between a rule and a suggestion: a default is droppable, so a sender could keep filename in the cleartext half with every unconditional check still passing. Only file_base64 is required unconditionally, because the other three are optional and a required entry would reject every request that omits one. This is Anthropic's top-level system treatment exactly, and the profile needs it for the same reason.

A sealed speech request MUST carry an explicit response_format, and its value MUST be one of json or verbose_json. The pin is a value set, not a single value — see §5.1, where the definition covers both.

The set is drawn by what the protocol can express, and the line is sharp rather than a matter of degree. text, srt and vtt return a body that is not a JSON object at all — plain text, or a subtitle track. There is no object to attach _e2ee to, so the transcript would travel in the clear; and there is no frame for §7 to describe and no aad for §8's respH to hash, so such a response is not merely leaky but unverifiable. Those three are therefore outside any sealed exchange this document can define, which is why the field is pinned rather than sanitized.

json and verbose_json are both on the JSON side of that line, and the set admits both because excluding verbose_json would cost the profile its timestamps, and with them subtitles — a product capability, not a detail. Admitting it is what §7.3 pays for in two places, and both are worth naming here because they are the only reason the narrower set was ever attractive:

  1. Two billable-cleartext locators, as alternatives. verbose_json responses commonly carry no usage block at all, only a top-level duration, so §7.3's required cleartext is satisfied by either.
  2. Conditional sealing on the response side. segments[] carries the transcript per segment, words[] per word, and the response's language is inferred from the audio — all payload. So the response sealed set is not the constant ["text"] but "text, plus each of segments / words / language the frame carries", with the receiver-side rule that makes it enforceable (§7.3, and §7.2 rule 6 for the same shape on the Anthropic profile).

Neither is an argument for a narrower set; they are the work the set implies.

What is deliberately left out, and how it comes back. text / srt / vtt are future work, and they do NOT come back by widening this set: a non-JSON body has no envelope, so serving them under sealing means the enclave returns verbose_json over the sealed channel and the RECEIVER renders the requested format locally from the sealed segments. That is a client capability plus a relaxation of the caller-facing contract, not a protocol change — the wire exchange stays exactly what this section already defines. Adding a value to this set is additive per §9 either way, and no version is bumped.

Contrast with the image profile's pin (§7.1), which looks identical and is argued differently. There, the field is required because the default is the leak — OpenAI defaults response_format to url for the DALL·E family, so silence publishes the images. Here the endpoint's default (json) is already in the permitted set, so requiring the field explicitly buys no safety; it is required for uniformity with §5.1's pinned-cleartext machinery and because an explicit value makes the refusal of srt legible to the caller. A profile author reading only §7.1 would conclude "pin because silence is dangerous"; on this profile the reason is "pin because three of the five values are inexpressible".

5.3.3 The speech profile is non-streaming: a conditionally pinned field

/v1/audio/transcriptions has a streaming shape (stream: true, delivering transcript.text.delta / transcript.text.done events). The speech profile does not cover it, so a sealed speech request MUST NOT request streaming: a sender MUST refuse to seal one, and an enclave MUST reject one it receives rather than emit frames whose shape this document does not define.

stream is therefore a conditionally pinned cleartext field: it may be absent, and when present its value MUST be the permitted one (false). The construct differs from §5.1's unconditional pin only in which way absence falls:

Pinned cleartext field Conditionally pinned
Absent violation — the server's own default applies, and the profile exists because that default is wrong fine — the endpoint's default is the value the profile wants
Present, permitted value fine fine
Present, other value violation violation
Sealed away, or unbound violation (§5.1) violation, same reasons

It is to the unconditional pin what §5.1's conditionally required payload field is to a required one, and those four are the whole taxonomy: required or optional presence, crossed with payload or pin.

It MUST be expressed as a permitted set, never as a set of refused values, and on a JSON-ified endpoint that is a correctness requirement rather than a style preference. §5.3's whole premise is that the enclave re-materializes the request as multipart/form-data, where every value is a string and stream=true is exactly how a real streaming request is written. So the values that must not get through are whatever the materialized form reads as true — true, "true", 1, "1", "yes", and whatever else the upstream's form parser is lenient about. That set is open, so no list of refused values over it can be complete; "must be false" is closed, and only a closed rule fails closed. An implementation MUST compare the value's materialized rendering rather than its JSON type, so the boolean false and the string "false" are one value — a sender carrying form fields across as strings is doing nothing wrong, since the form they came from had no types.

This is not a hypothetical failure mode. The first implementation of this profile expressed the rule as a blacklist compared by exact JSON type, and "stream": "true" and "stream": 1 both passed both ends — producing exactly the router/enclave split brain described below, on the only profile that has this rule. Its tests asserted that as intended, reasoning that "the upstream rejects such junk": true of a JSON endpoint, false of a JSON-ified one.

The error message MUST name the permitted value rather than the refused one. A message that says "must not be true" invites an operator to try the nearest other spelling, which on this endpoint is the bypass.

It MUST NOT be sealed or declared unbound. Unbinding is refused for a pinned field's reason exactly — outside the AAD, an intermediary can set it in transit while Open and the §8 verification both still pass — but sealing is refused for a different and worse reason, and a profile author who reasons by analogy with a pin will get this wrong. A pin sealed away leaves the server reading nothing where the pin should be, so it falls back to its own default. A sealed stream does not fall back to anything: §6 has the enclave reconstruct request = cleartext ∪ decrypted and forward the result, so stream: true sealed inside the envelope reaches the upstream while the router — which reads only the cleartext — sees a non-streaming request. That is not a default being taken; it is the two halves of the system disagreeing about the shape of the response, each acting correctly on what it was told.

The refusal is because the shape is undefined, not because it is unsafe. Adding it later means a frame taxonomy in the shape of §7.2 (a discriminator, per-shape content fields, terminal shapes including error, and the order-sensitive streaming respH of §8.1) — additive per §9, and a real piece of work rather than a flag.

6. Request seal / open

Seal (client):

sealed_obj = { field: original_value  for field in sealed_fields }
pt         = serialize(sealed_obj)          // JSON; canonicalization NOT required (§5.1)
(enc, ctx) = HPKE.SetupBaseS(enc_pub, info="0g-pc/v1/seal")
// build _e2ee with everything except ciphertext, drop sealed fields from the body
aad        = JCS(envelope_without_ciphertext_and_without_unbound_fields)
ciphertext = ctx.Seal(aad, pt)

The client MUST retain the ephemeral private key behind client_eph_pub to open the response (§7).

Open (enclave):

select enc_key by key_id; verify v, kem_id
aad = JCS(received_envelope_without_ciphertext_and_without_unbound_fields)
ctx = HPKE.SetupBaseR(enc, enc_priv, info="0g-pc/v1/seal")
pt  = ctx.Open(aad, ciphertext)          // MUST fail-closed on error
verify keys(pt) == sealed_fields; pt has no _e2ee key; no collision with cleartext; signer_addr == teeSignerAddress
reconstruct request = cleartext_fields ∪ pt

If key_id matches no current enc key, Open fails, or any check fails, the enclave MUST reject (no plaintext fallback).

7. Sealed response envelope (v1)

The response is field-level, symmetric with the request: the enclave seals only the sensitive fields (chat profile: choices — the generated content and per-choice finish_reason; image profile: data — the generated images; embedding profile: data — the vectors, §7.4; speech profile: text and its conditional siblings, §7.3; anthropic profile: a field per event shape — §7.2), and leaves the rest cleartext so the router can bill on them. Cleartext response fields (usage, model, id, created, system_fingerprint) are:

  • readable by the router (no decryption needed),
  • bound in the seal AAD, so the client detects any tampering, and
  • covered by the TEE signature (§8), so usage is authenticated to the client/auditor without decrypting choices — a lying provider is caught at verify. (Fee settlement itself is anchored by a separate on-chain-verified signature over the fee tuple, not by this response signature — see §8.2.)

Sealing is a fresh HPKE setup, enclave as sender, client_eph_pub as recipient. Streaming frames are sealed under one response context (its internal sequence increments per Seal, so frames MUST be opened in order).

(resp_enc, resp_ctx) = HPKE.SetupBaseS(client_eph_pub, info="0g-pc/v1/resp")   // SetupPSKS when the request carried _bind, §7.6
// per frame, in order:
sealed_obj = { field: value  for field in sealed_fields }   // e.g. { "choices": [...] }
aad        = JCS(frame_json without _e2ee.ciphertext and without _e2ee.unbound_fields)
ciphertext = resp_ctx.Seal(aad, serialize(sealed_obj))       // no JCS on the body (§5.1)

Response frames may also carry unbound_fields (same semantics as §5.2): the denylist of cleartext frame fields an intermediary may inject/modify — e.g. a router that folds a trace object into the final frame. Such fields are excluded from the AAD and, per §8, are not covered by the signature, so nothing may trust them.

Non-streaming — the response body is one frame:

{
  "id": "chatcmpl-...",
  "model": "gpt-4o",
  "created": 1700000000,
  "usage": { "prompt_tokens": 10, "completion_tokens": 20, "total_tokens": 30 },
  "_e2ee": {
    "v": 1,
    "enc": "<base64url resp_enc>",
    "sealed_fields": ["choices"],
    "final": true,
    "ciphertext": "<base64url>"
  }
}

Streaming (SSE) — one event per frame; enc on the first, usage on the final (per stream_options.include_usage); each event seals that chunk's choices delta:

data: {"model":"gpt-4o","_e2ee":{"v":1,"enc":"<resp_enc>","sealed_fields":["choices"],"final":false,"ciphertext":"<...>"}}
data: {"model":"gpt-4o","_e2ee":{"sealed_fields":["choices"],"final":false,"ciphertext":"<...>"}}
data: {"usage":{...},"_e2ee":{"sealed_fields":["choices"],"final":true,"ciphertext":"<...>"}}

7.1 Image responses

An image response is one non-streaming frame with data sealed:

{
  "created": 1700000000,
  "model": "z-image",
  "usage": { "output_images": 2 },
  "_e2ee": { "v": 1, "enc": "…", "sealed_fields": ["data"], "final": true, "ciphertext": "…" }
}

Two constraints are specific to this profile:

  • usage.output_images is the billable count and MUST be cleartext. The router bills per delivered image and cannot count a sealed data[]. The enclave writes the count of images it actually delivered (not the requested n — a provider may clamp it), bound in the AAD and covered by the §8 signature, so the router bills without decrypting and a lying count is caught at verify. An enclave that cannot count them MUST reject rather than seal a response with no verifiable count.

    This is enforced on both sides, on the final frame (usage is a property of the whole response; a streaming profile may withhold it until the last frame). A sealer MUST refuse to emit a final frame that omits the count, and a client MUST refuse a final frame that omits it — the receiver half being the one that holds when the enclave is not running this library. The value MUST be a non-negative whole number. An explicit 0 is a valid count and is not an omission; null is an omission and is not a zero. Fractional and exponent forms (2.5, 1e3) are legal JSON numbers but are NOT valid counts — the rule is a whole number so that a producer cannot seal a value the router will refuse to bill.

    The receiver half exists because omission has no loud failure anywhere else: a router parses such a frame perfectly well, counts zero images, and bills nothing. A missing count and a genuine zero are the same bytes downstream, so no component but the client can tell them apart, and only at open time.

    It lives inside usage because that is where a quantity billed on belongs, and is named output_images rather than images for two reasons: usage is an OpenAI-defined object (a token-billed image model such as gpt-image-1 populates it with input_tokens / output_tokens / input_tokens_details, while a per-image model such as dall-e-3 omits it entirely), so an extension to it should not squat an unqualified common word; and a future image-editing profile has input images, against which a bare images would read ambiguously. The input_/output_ prefixes are OpenAI's own convention in this object. Any token fields the model reports are preserved alongside it; only output_images is written by the enclave.

  • A sealed image request MUST carry an explicit response_format: "b64_json". Not "must not be url" — must be present and must be b64_json. URL mode has the enclave persist the images and serve them from a plain URL, which puts the plaintext images (the generated content itself, a worse leak than the prompt) outside the sealed channel and defeats the profile.

    The field is required, not defaulted, because the default is the leak: OpenAI's response_format defaults to url for the DALL·E family (only gpt-image-1 always returns b64_json). So an omitted field is a request to publish the images in the clear, spelled as silence — a rule phrased as "reject url" would let it through while looking correct.

    A client MUST refuse to seal a request that violates this, at seal time, before any ciphertext exists (wire.SealRequestFor does), and MUST NOT list response_format in unbound_fields — an unbound pin binds nothing after the seal (§5.1). An enclave MUST reject a violating request it receives, rather than silently downgrading to b64_json — the caller asked for a format this mode cannot honour and has to learn that. The enclave check is not redundant with the client one: it is the half that does not depend on the sender.

    The general rule this instantiates: a cleartext field that directs the server to publish the RESULT outside the sealed channel is part of the profile's contract, and sealing the payload is not sufficient on its own. A future profile that gains such a field must pin it the same way.

7.2 Anthropic responses (frame-typed profiles)

The chat and image profiles repeat one frame shape: every frame seals the same field. An Anthropic response does not. Its events are structurally different from one another — some carry generated content, some are pure sequencing — so what a frame must seal is a property of the frame, not of the profile. Such a profile is frame-typed: it names a cleartext discriminator field and maps each of its values to a shape.

For /v1/messages the discriminator is type, which Anthropic already puts in every event payload:

type Seals Cleartext Ends the stream
message (non-streaming) content, stop_sequence when present id, type, role, stop_reason, usage; model (unbound †) n/a — one frame, final by definition
message_start — type, message ‡
content_block_start content_block type, index
content_block_delta delta type, index
content_block_stop — type, index
message_delta delta (stop_reason, stop_sequence) type, usage (output_tokens)
message_stop — type yes
ping — type
error error type yes

† model is cleartext but unbound, like every other profile — the router rewrites the served model back to the alias the caller asked for, and the resulting value is not authenticated (§5.2, DefaultUnboundFields). Do not bind it: a broker that did would fail the client's Open on every response the router touched. Everything else in the cleartext column is bound.

‡ message is a protected cleartext field — see rule 4. On a streaming response the billable input count lives inside it, at message.usage.input_tokens.

Two shapes end the stream. message_stop closes a completed turn and error closes one that failed partway — a turn that fails sends error and no message_stop. So final: true belongs on whichever of the two actually arrives; an enclave that recognized only message_stop would emit an error stream with no final frame at all, which §7 requires the client to reject as a truncation. (The reference implementation answers this through wire.IsTerminalResponseFrame rather than leaving each enclave to hardcode it.)

Six rules make that table safe. The first two are the shape contract; the rest exist because each is a way to satisfy the table and still leak. Rules 4 and 5 are also the two whose SCOPE matters as much as their content: a rule attached to the shape where a field is expected, or to a field name a nested value does not share, holds in exactly the case nobody was attacking.

  1. A shape with a content field MUST seal it. As §7 elsewhere.

  2. A shape with no content field MUST seal nothing — an empty sealed_fields. It is still a sealed frame: the ciphertext is over an empty object, so the frame still carries _e2ee, its cleartext is still bound in the AAD, and §8 still covers it. "Anything goes" is not the alternative: message_start's message holds the input token count the router bills on, so a sealer free to seal extra on a sequencing frame could make the response unbillable.

  3. No frame may carry ANY shape's content field in cleartext without sealing it. Rules 1–2 key off the frame's own type, so a frame that claims message_stop and puts the answer in a cleartext delta satisfies "seals nothing" exactly, opens cleanly, and reads to an SDK as a stray field on a stop event — while every intermediary has the delta. Keying this rule off the field names is what makes a mislabeled frame detectable whatever it calls itself.

  4. message is a PROTECTED cleartext field: it may not be sealed, may not be unbound, and message.content must be empty or absent — on EVERY frame that carries it, whatever the frame's shape. All three follow from one fact: the billable input count is inside it, so the router must be able to read a value it cannot decrypt and an intermediary cannot rewrite. Sealing the field is worse than rewriting the count — the router then finds it on no frame at all and bills zero — and a legal superset is what makes that reachable from a content frame, so "seal nothing extra" (rule 2) does not cover it. The emptiness half exists because a cleartext container can smuggle: Anthropic's schema fixes message.content to [], and an enclave that disagreed would ship the answer in the clear in a field an SDK actually renders.

    The scope is the field, not the shape. Anchored to message_start — the only shape where message legitimately appears — the emptiness rule held on exactly one shape, and a ping or message_stop frame could carry message: {"content": [...]} untouched: rule 3 does not name message, and "seals nothing" was satisfied. A profile that nests a value the router needs MUST declare the field once and derive every rule from it, rather than attaching each rule to the shape where the field is expected.

  5. The discriminator MUST stay cleartext and MUST NOT be unbound. Every rule above keys off it, so a sealed one makes the shape unknowable and an unbound one lets an intermediary relabel a content frame as a sequencing frame — after which the receiver applies the wrong shape's rules and passes. This is the §12 row about a receive-side check gated on a sender-controlled value.

  6. A shape MUST seal stop_sequence whenever the frame carries it. It is the custom stop string the CLIENT supplied in stop_sequences, echoed back — the caller's own input, not model output. The streaming path seals it already (it lives inside message_delta's delta); the non-streaming shape carries it as a top-level field of its own and so has to name it, or the same value would go one way in one mode and the other way in the other. It matters exactly when a client adds stop_sequences to its request's sealed set — which §5.1's defaults explicitly invite — because the response would then hand back in the clear a value the request deliberately sealed. stop_reason is deliberately NOT covered: it is a model-produced enum (end_turn, max_tokens, …) with no caller input in it, and the router reads it; streaming seals it only because it shares the delta object with the stop string.

Rule 6 is "sensitive but optional" — the response-side twin of §5.1's conditionally required payload field — and is checked the same way from both ends: a field still present in the received frame's cleartext was never sealed.

An unrecognized discriminator value is REJECTED, not passed through: an unknown shape may carry content and nothing about it says otherwise. Adding a shape is additive (§9) and needs no version bump, but until a shape is added, frames bearing it cannot be served.

The SSE event: line is not the discriminator and MUST NOT be trusted. It sits outside the frame JSON and therefore outside the AAD, so an intermediary can rewrite it undetected. A receiver MUST read the shape from the bound type field and rebuild the event: line from it.

Two notes on the token counts. They are cleartext and bound — rule 4 is what makes the second half of that true for input_tokens, whose nesting puts it out of reach of the general rule — but they arrive on non-final frames and one of them is two levels inside message, so unlike the image profile's usage.output_images (§7.1) they are not required by this spec to be present. Their being unforgeable and their being present are separate properties: rule 4 settles the first, and the second is a tracked follow-up. The failure mode if an enclave omits them is the §7.1 one exactly — a router reads zero, cannot tell that from a genuine zero, and bills nothing.

Client open: SetupBaseR(resp_enc, eph_priv, info="0g-pc/v1/resp"), then Open each frame in order (fail-closed), merge the decrypted choices back with the cleartext fields. The client MUST receive a frame with "final": true before treating the response as complete — a missing final frame is a truncation and MUST be rejected. final is in the AAD, so a flipped flag is detected.

7.3 Speech-to-text responses

A transcription response is one non-streaming frame (§5.3.3). json:

{
  "model": "whisper-large-v3",
  "usage": { "type": "duration", "seconds": 12.5 },
  "_e2ee": { "v": 1, "enc": "…", "sealed_fields": ["text"], "final": true, "ciphertext": "…" }
}

verbose_json, which additionally carries the per-segment transcript and can report its duration only at the top level:

{
  "model": "whisper-large-v3",
  "task": "transcribe",
  "duration": 12.5,
  "_e2ee": { "v": 1, "enc": "…", "sealed_fields": ["text", "segments", "language"], "final": true, "ciphertext": "…" }
}

Two constraints are specific to this profile.

  • The sealed set MUST cover text, and MUST additionally cover each of segments, words and language that the response carries. These are conditionally sealed on the response side, exactly as the top-level system is on the Anthropic REQUEST side (§5.1): they need not exist, but a frame that has one and does not seal it is refused. segments[] holds the transcript per segment and words[] per word — the same content as text, cut differently — and language here is not the caller's request hint but the language the enclave inferred from the audio, so all three are payload.

    A profile-wide constant sealed set cannot express this: verbose_json carries segments and, only when word granularity was asked for, words, so requiring all of them rejects conforming responses and requiring none of them is the leak.

    The client is the half that holds (§12). At seal time the fields are still in hand, so a sealer knows what it must seal; at open time a field that WAS sealed is already gone from the cleartext, so one still sitting there was never sealed — and nobody else can tell. A router forwards such a frame unremarkably, and the transcript rides through it in the clear.

  • The billable quantity MUST be cleartext, bound, and present — as EITHER usage.seconds OR the top-level duration. This is the §7.1 usage.output_images rule with two alternatives and a sharper failure mode.

    The alternation is not laxity: it is the two shapes upstreams actually emit. json responses carry {"type": "duration", "seconds": N}; verbose_json responses commonly carry no usage block at all and report the audio length only as a top-level duration. A frame satisfying either is compliant, and a frame carrying both MUST NOT disagree — the seal-time half rejects that, since a receiver reading one locator and a router reading the other would otherwise bill different numbers for one response.

    N is a non-negative finite JSON number, and unlike §7.1's whole-number image count, fractional values are valid — audio duration genuinely is fractional. Infinities, NaN-alikes and negatives are not. It MUST be a JSON number, not a quoted string: a consumer may tolerate "12.5" defensively against non-conforming providers, but this document does not bless the form, because a value that is sometimes a string is a value every implementation must write two parsers for. An explicit 0 is a valid quantity; null, or neither locator present, is an omission and is not a zero.

    A locator's containing field being null is an ERROR, not an unused alternative. "usage": null is the block being present and not being an object, so the frame is malformed and MUST be refused — distinct from usage being absent, which is simply this alternative not being used and is fine when the other locator carries the value. The distinction needs stating because it is where an implementation slips: decoding a JSON null into a map succeeds and yields an empty one, so "usage": null reads as "the key is not in there" and the requirement is then satisfied by duration alone. null is also the likeliest value an upstream emits for a block it did not populate, so this is the common case rather than the exotic one — the reference implementation had exactly this bug while correctly refusing "usage": 7.

    The enclave MUST write it even when the upstream response omits it, deriving the duration from the audio it decoded, and MUST reject rather than seal a response whose duration it cannot establish. This is not the same obligation as forwarding the upstream's own count: on this endpoint the upstream commonly reports nothing, and a per-second price applied to nothing is not an error anywhere.

    Enforced on both sides. A sealer MUST refuse to emit a frame that omits it, and a client MUST refuse a frame that omits it — the half that holds when the enclave is not running this library.

    The receiver half matters more here than in §7.1. There, an omitted count makes the router bill nothing — wrong, but at least monotone in one direction. Here the router's existing behavior on a response with no usage block is to estimate from the transcript text, and under sealing that text is empty, so it falls through to a flat constant. An omitted duration therefore does not under-bill quietly; it bills a fabricated number that is unrelated to the audio and looks entirely plausible on a dashboard. Nothing downstream can tell that number from a real one, and only the client can refuse the frame that produced it.

A profile with conditionally sealed response fields has no profile-wide sealed set, and an implementation MUST NOT offer one. The tempting answer for this profile is ["text"], and it is worse than incomplete: it is valid for every json transcription and invalid for every verbose_json one, so an enclave holding it passes all of its own testing and then fails 100% of verbose responses the first time a client asks for timestamps — after the upstream call, which is already paid for. A frame-less accessor MUST therefore answer with the empty set for such a profile, exactly as it does for a frame-typed one (§7.2), so the mistake surfaces on the first request rather than on the first verbose one. Resolving the set against the frame is the only correct way to name it.

Everything else stays cleartext and bound: model, and verbose_json's task. Note that the duration is cleartext under both permitted formats, so admitting verbose_json reveals nothing new — the length of the audio is already visible to the router in the json case, as the thing it bills on. The sealed channel protects what was said, not that something was said for 12.5 seconds.

7.4 Embedding responses

An embedding response is one non-streaming frame with data — the vectors — sealed:

{
  "object": "list",
  "model": "qwen3.7-text-embedding",
  "usage": { "prompt_tokens": 14, "total_tokens": 14 },
  "_e2ee": { "v": 1, "enc": "…", "sealed_fields": ["data"], "final": true, "ciphertext": "…" }
}

The vectors are sealed because they are recoverable content, not a digest: embeddings of one corpus are comparable to each other and to any vector an attacker computes independently, so a readable data discloses the corpus' structure — and, against a known candidate set, its text — without the text ever appearing. Treating them as opaque numbers is the mistake this rule forecloses.

Two constraints, one on each side, and they are asymmetric: the request side adds nothing at all (no pinned field, no conditional payload — see §5.1), while the response side carries exactly one requirement.

usage.prompt_tokens is the billable count and MUST be cleartext on the final frame. The router bills an embedding response on input tokens, and the enclave MUST write the count even when the upstream omitted one. This is the same requirement §7.1 places on image's usage.output_images, and the reason it cannot be left to the usage floor is a distinction worth stating outright:

Kept readable is not the same as required present. §5.2/§7 forbid sealing usage and forbid declaring it unbound. Neither requires the field to exist. A frame carrying no usage at all therefore satisfies every floor rule in this document — so the requirement has to be stated per profile, as image's is.

The gap it closes is worse than image's, because sealing removes the router's own fallback rather than merely its ability to count. A router that receives no usage on this endpoint has nothing in the response to estimate from — an embedding response echoes no text back — so it estimates from the request's input, which this profile seals. It then floors to a flat constant, and an arbitrarily large sealed batch bills as one token, silently. Nor does that merely under-bill: the enclave holds the decrypted input, so it bills the provider an accurate count while the router charges the caller for the constant — a margin error in a known direction. Compare §7.3's speech row, which is the same failure.

chat's exemption does not transfer. It rests on a streaming response legitimately withholding usage until the caller asks for it (stream_options.include_usage), which an endpoint with no stream parameter cannot claim; this profile's one frame is always the final frame.

The locator is usage.prompt_tokens alone, with no alternative in the manner of §7.3's usage.seconds / duration. Speech needs the alternation because upstreams genuinely report the duration in two places and naming one would reject half of the conforming responses; here the enclave writes the frame, so an upstream that reported only total_tokens is normalized by the enclave (an embedding response has no completion side, so the total is the prompt count) rather than by every reader. One locator also avoids the agreement rule alternation drags in.

No restated vector count, on the other hand, is correct: nothing bills on how many vectors came back, so requiring one would enforce a number no party reads.

What stays visible, and is worth naming because this endpoint's traffic pattern is more revealing than chat's: usage.prompt_tokens gives the router the total length of the text embedded, and model names the embedding space. A retrieval pipeline indexes in batches, so a router observing token counts over time learns roughly how large a corpus is and when it was ingested — never a word of it, but not nothing. This is the same floor every profile has (billing needs usage, routing needs model) rather than anything new here; it is called out because the inference from "batch sizes and timing" to "corpus shape" is a shorter step than the equivalent for a conversation.

7.5 Async image responses (deferred results)

Profile async-image is the image profile split in time. The request is sealed and submitted; the broker answers 202 with a job id and stores the sealed envelope; inside the enclave a worker opens it, generates, and seals the result once, at completion; every later poll returns that stored frame. The result is a fresh response context to client_eph_pub (§7), in mode_psk keyed by the request's binding value (§7.5.1, §7.6). It needs only what the request carried, never the request's HPKE context. This section specifies what the time split breaks.

Senders. The rules below are written for a stateless sender (the cloud gateway): one that holds nothing between the submit and the poll. A sender that does keep state MAY additionally check the §8 signature against its reqH, but MUST still apply every rule here.

7.5.1 Result binding (_bind)

client_eph_pub is cleartext in the request envelope, and HPKE base mode does not authenticate the sender. So a router can submit a request of its own, with a prompt of its choosing, carrying a victim's client_eph_pub. A genuine enclave serves it, seals the result to the victim's key, and signs it under the router's reqH. The router returns that frame on the victim's poll. It opens and its signature verifies, but the content is the router's. A sync sender is protected by checking reqH; a stateless one has no reqH at poll time. A fresh eph key per job does not help: the attack reuses the victim's key for the same job.

The response binding (§7.6) closes this without state, and on this profile it is mandatory:

  • Request. The sender adds _bind (§7.6) to the request and seals it. It is a mandatory payload field of this profile: the sender MUST seal it, and the enclave MUST refuse an envelope whose sealed_fields omit it (§5.1 rules).
  • Enclave. After Open, the enclave MUST remove _bind before the upstream call (it is protocol metadata, not a model parameter), keep it with the job, and seal the result in mode_psk keyed by it (§7.6). It MUST NOT seal an async-image result in base mode.
  • Receiver. The receiver recomputes bind from the eph_priv it holds and opens the result in mode_psk with it. It MUST NOT open an async-image result in base mode. A result that does not open is refused.

Why this holds:

  • Only the victim's request carries the right _bind. It is derived from eph_priv, which never leaves the sender, and it travels sealed.
  • Only the enclave the request was sealed to can learn it.
  • So a result that opens under the victim's binding was produced by that enclave for that request. The router's own request carries a binding of the router's choosing, so the enclave seals its result under that one, and it does not open for the victim.
  • The router cannot forge a result, cannot substitute its own job's result, and cannot edit a bound cleartext field: it cannot re-seal without bind.

Content authenticity on this profile therefore does not depend on §8. The signature is still produced, for auditors and as billing evidence.

The binding value is §7.6's, so its known answer is there.

7.5.2 The poll response

The whole completed poll response is the result frame, so the poll envelope's own fields are inside the AAD:

{
  "jobId": "<broker-assigned job id>",
  "status": "completed",
  "createdAt": "…", "updatedAt": "…",
  "model": "…",
  "usage": { "output_images": 2 },
  "_e2ee": { "v": 1, "enc": "…", "sealed_fields": ["data"],
             "unbound_fields": ["model", "x_0g_trace"], "final": true, "ciphertext": "…" }
}
  • The sealed data is the provider's image response, exactly as the cleartext poll would embed it, in mode_psk keyed by the request's binding (§7.5.1). After Open the receiver returns the poll envelope in its ordinary shape.
  • usage.output_images is the billable count, under the same rules as §7.1.
  • unbound_fields is exactly ["model", "x_0g_trace"], declared by the enclave at completion. It lives inside _e2ee, which is bound, so nothing can extend it later; the router injects x_0g_trace on completed polls, so a frame without it fails to open on the first traced poll. model is unbound as on every sealed response (§5.2). A receiver MUST refuse a frame that declares any other field unbound: jobId and status are what its checks key off.
  • Which job. The receiver MUST require status == "completed" and jobId equal to the broker-assigned id it is polling. Both are bound, so the AEAD authenticates them. This refuses a frame from another job — including a replay of the victim's own request, which carries the same binding but receives a new broker job id.
    • The comparison is on the broker-assigned id. Today the image job id a client sees is that id verbatim. If an outward id format is ever introduced for images (video already composes one), the receiver MUST derive the broker id from it explicitly; it MUST NOT compare the outward form.
  • The frame is immutable for the job's lifetime. The enclave seals once and the broker stores the frame (cleartext part and ciphertext); every poll returns the same bytes, modulo the declared unbound fields.

Non-terminal and failed responses are not frames. They carry no _e2ee, and for a sealed job their content is restricted, so that nothing sensitive rides in the clear:

status Body for a sealed job
pending / processing {jobId, status, createdAt, updatedAt} only
failed {jobId, status, createdAt, updatedAt, errorMessage}, where errorMessage is a fixed error code (e.g. upstream_error:422, timeout, broker_error). The enclave MUST NOT store or return the upstream error body for a sealed job: moderation errors can echo the prompt
completed the result frame above

These bodies are cleartext and unauthenticated. A router can misreport status, holding a job at processing or claiming failed. That is denial of service, equivalent to dropping the request. It cannot read or inject content.

7.5.3 The user job id

The stateless sender keeps eph_priv in the id it returns to the user:

user_job_id = jobId + "." + base64url(eph_priv)        // unpadded; 32 bytes → 43 characters
  • Parsing: split on the last .. The job-id half MUST be 1–128 characters of [A-Za-z0-9_-]. The key half MUST be unpadded base64url decoding to exactly 32 bytes. Anything else is refused, and the refusal MUST NOT echo the input.
  • The sender MUST remove the key segment before forwarding anything to the router, and MUST NOT log a full user_job_id.
  • The sender MUST replace jobId with user_job_id in the submit response and in every poll response it returns.
  • A job id is a credential. A third party holding it still cannot fetch the result: the router enforces ownership. The router operator can decrypt that one job, since it relays the frame. Wrapping the key under a sender-held key does not change this. A router holding a leaked id can replay it through the sender, because in zero-client-code deployments the user's credential passes through to the router.

7.5.4 Sender rules at submit and poll

  • Submit pinning. The sender seals to one attested enclave, so the submit MUST pin that provider (X-0G-Provider-Address, plus X-0G-Provider-Identity when the candidate has one) with fallbacks disabled. It MUST check that the 202's provider_address equals the pin. A request routed elsewhere cannot be opened there.
  • Poll handling (key segment = present in the polled id):
Key segment Response Sender
present pending / processing, no _e2ee relay jobId, status, createdAt, updatedAt only
present failed, no _e2ee relay; an errorMessage that is not a known code is replaced by a generic one
present completed with _e2ee §7.5.1–§7.5.2 checks → plaintext; refuse if any fails
present completed without _e2ee refuse (downgrade)
present _e2ee with any other status refuse
absent carries _e2ee refuse: never pass a sealed frame through, never present it as cleartext
absent no _e2ee cleartext passthrough
  • A non-2xx response to a keyed poll is relayed as its status and a known error code only. The body is never relayed verbatim (a relayed upstream error may carry request text), and the error never includes user_job_id.

7.6 Response binding (synchronous profiles)

client_eph_pub is cleartext in the request envelope and HPKE base mode does not authenticate the sender, so anyone who saw the request — the router included — can seal a response the client will open. On a non-streaming response §8 catches this before delivery. A stream is handed to the caller frame by frame and §8 runs only after the final frame, so a router that drops the enclave's stream and answers with one of its own has its text delivered first and reported as a failure afterwards. It cannot splice a frame into the genuine stream (later frames are bound to the first frame's context by the AEAD sequence); it replaces the whole stream, starting at the first frame.

The response binding closes this. It applies to every profile: optional, per request, on the synchronous ones (chat, anthropic, image, speech, embedding), and mandatory on async-image, whose stateless sender has nothing else to tie a result to its request (§7.5.1).

bind = HKDF-SHA256(ikm = eph_priv, salt = "", info = "0g-pc/v1/resp-bind", L = 32)
_bind = base64url(bind)                      // unpadded, a JSON string

// enclave, when the opened request carried _bind:
(resp_enc, resp_ctx) = HPKE.SetupPSKS(client_eph_pub, info = "0g-pc/v1/resp",
                                      psk = bind, psk_id = "0g-pc/v1/resp-bind")
// client:
resp_ctx = HPKE.SetupPSKR(resp_enc, eph_priv, info = "0g-pc/v1/resp",
                          psk = bind, psk_id = "0g-pc/v1/resp-bind")

Everything else in §7 is unchanged: frames, sealed sets, AAD and _e2ee are the same, and nothing is added to a frame.

  • Request. The sender MAY add _bind to the request. When it does, it MUST seal it. A request carrying _bind in the clear MUST be refused by the sender at seal time and by the enclave at open (a cleartext value is one the router holds).
  • Enclave. After Open, if the request carries _bind, the enclave MUST remove it before the upstream call (protocol metadata, not a model parameter) and MUST seal the response in mode_psk keyed by it. Without _bind, it seals in base mode as before.
  • Receiver. A sender that attached _bind MUST open the response in mode_psk with the bind it derives from eph_priv, and MUST refuse a response whose first frame does not open, before delivering any of it.

Why this holds: bind is derived from eph_priv, which never leaves the sender, and travels only sealed to the enclave. A party without it — the router, or anyone else holding client_eph_pub — can set up only a context the receiver's cannot match, so its first frame fails to open. Every later frame shares that first frame's context. One check at the first frame therefore authenticates the whole stream, which is why the binding keys the context rather than riding in a frame: no frame-shape rule changes (Anthropic's message_start still seals nothing, §7.2), and no frame grows.

Opt-in per request on the synchronous profiles. An enclave that predates this section forwards _bind upstream and seals in base mode, and a client that attached _bind would refuse every response from it. A sender therefore attaches _bind only for an enclave it knows implements §7.6. How it learns that is outside this format. The 0G broker advertises "e2ee_features": ["resp-bind"] in its /v1/quote reply, and the gateway reads it only from the provider's on-chain serving URL, which the router cannot redirect. A sender that relied on anything the router relays could be downgraded into not attaching it.

Known answer: eph_priv = bytes 00 01 02 … 1f → bind = 99711ebcd2996833c92f9fd7ddc8ac3e9c2bb5513b36d3ce72049b34c21e4a8b.

8. Response signature

Each response carries a TEE signature that authenticates it as the enclave's output for this exact request. It is a standalone artifact, fetched separately from the response body: the response carries a ZG-Res-Key: <chatKey> header, and the client GETs <provider>/v1/proxy/signature/{chatKey} directly from the provider's broker endpoint (the router does not proxy this path). Because the signature is content-bound and anchored on-chain, fetching it over an untrusted path is safe — a forged or absent reply fails verification fail-closed.

The fetched ChatSignature { text, signature, signing_address, signing_algo } is verified as:

  1. Parse the scheme — text = "<scheme>:<reqH>:<respH>". The scheme tag is inside the signed text (it cannot be relabeled by an intermediary). An implementation MUST reject a scheme it does not implement (fail-closed, §9).
  2. Recompute the content binding — reqH/respH MUST equal the client's own hashes, computed by mode (below).
  3. Recover the signer — addr = ecrecover(EIP191(text), signature) (ECDSA secp256k1, personal_sign).
  4. Accept only if addr == on-chain teeSignerAddress — the quote-bound signer, grounded on-chain (§4.4 / hop 5); never the self-reported signing_address (a hint only).

8.1 Binding by transport mode

The binding hashes different artifacts depending on how the response travelled. Every ‖ below joins only fixed-width 32-byte values (each variable-length input is hashed first), so concatenation is injective — no separators, no length prefixes. Define H(aad, ct) = sha256( sha256(aad) ‖ sha256(ct) ).

E2EE (ciphertext binding) — schemes zg-sig-v1/e2ee-ct (non-stream) and zg-sig-v1/e2ee-ct-stream (streaming). The verifier hashes the on-wire artifacts it already holds — aad (the JCS'd cleartext manifest minus unbound_fields, §5.2) and ciphertext — with no decryption and no canonicalization of the sealed content (both sides hash identical bytes; this is why the sealed body is not JCS'd, §5.1; the AEAD transitively binds ciphertext↔plaintext):

reqH  = H(aad_req,  ct_req)                          # request half, both modes
respH = H(aad_resp, ct_resp)                         # non-stream
respH = sha256( H(f_0) ‖ H(f_1) ‖ … ‖ H(f_{n-1}) )  # streaming, frames in send order, final last
        where H(f_i) = H(aad_i, ct_i)

The streaming respH is order-, count- and truncation-sensitive: a dropped, reordered, or missing-final frame changes it (double-covering the §7 "final frame required" rule).

Plaintext (plaintext binding) — scheme zg-sig-v1/plain, for a plaintext (non-E2EE) exchange (e.g. a browser directly to the broker). There is no ciphertext, so the binding is over the plaintext, one hash per half:

reqH = sha256( JCS(req) )     respH = sha256( JCS(resp) )

This is verified out of band by an auditor after the fact — a plaintext-mode response never traverses the E2EE client — so its verifier is not part of the E2EE client. (Streaming plaintext binding is owned by the broker/audit side.)

8.2 Invariant and trust

The signature covers exactly the non-unbound_fields content. aad is the cleartext manifest minus the unbound set, and ciphertext is the sealed content — together, everything except the intermediary-mutable fields. A party holding only the on-wire artifacts (e.g. the router) can therefore verify an E2EE signature and read a bound cleartext field like usage without decrypting choices. Corollary: any value that must be cryptographically trusted MUST NOT be unbound — a router-injected x_0g_trace is unauthenticated by construction. Note that response billing does not rely on this signature: fee settlement uses a separate on-chain-verified TEE signature over the fee tuple (0g-serving-broker settlement path), so §8 exists for response authenticity and the client/auditor's content check, not for the router to bill on.

Verification MUST be fail-closed. The signed-text format and binding are defined once, byte-for-byte, in the shared protocol/proof package (imported by both the broker signer and the client verifier) and locked by the §10 KATs.

9. Versioning

  • _e2ee.v, the response v, the report_data version, and the signature scheme tag (§8, e.g. zg-sig-v1/…) are independent and each bumped on a breaking change to their format.
  • A new HPKE suite, a new AAD/info rule, or a new report_data layout MUST bump the relevant version; implementations MUST reject versions they do not implement.
  • The signature scheme tag is a self-describing profile carried inside the signed text: one tag pins {algo, hash, canonicalization, binding}. A breaking change to any of those (a different hash, the concat convention, the binding artifacts) bumps the profile version (zg-sig-v1/… → zg-sig-v2/…); a verifier MUST reject an unknown scheme fail-closed.
  • Adding a routing field, a new sealed/unbound field, or a new request profile (§5.1) is NOT a version bump — cleartext fields are additive (unknown keys ignored by the router), sealed_fields is self-describing, and unbound fields are outside the signature anyway. Only the crypto/format envelope and the signature profile are versioned.
  • Consumers (broker, router, client) update in lockstep with a version bump.

10. Test vectors

Each release MUST ship KATs so Go/TS/Rust — and the broker signer — match byte-for-byte.

Envelope KATs: fixed enc_priv/enc_pub, a fixed eph_priv/client_eph_pub, a fixed original request, the expected JCS of the sealed object and of the AAD, the expected _e2ee (incl. ciphertext), and fixed response chunks with expected resp_enc + frame bytes. KATs MUST pin the JCS output to lock canonicalization.

A profile with a binary payload field (§5.3) MUST additionally pin the encoded field value itself, from fixed raw bytes — the speech profile's file_base64 from a fixed short audio blob. Its encoding is standard base64 with padding while every §3 wire field is base64url without, so the two conventions coexist in one implementation and a KAT over the JCS'd sealed object alone would not catch a decoder wired to the wrong one: both alphabets accept the other's output for many inputs, and the mistake surfaces as a corrupt payload inside the enclave rather than as a failed Open. The fixed bytes MUST therefore include at least one byte triple that encodes differently under the two alphabets (any input producing + / /) and a length requiring padding.

Signature KATs (§8): for the fixed request and response above, pin every intermediate so the binding cannot drift between implementations — aad/ct per sealed envelope, each sha256(aad) and sha256(ct), H(aad,ct), the per-frame H(f_i) and the aggregate respH for streaming, the final signed text (incl. its scheme tag), a broker-produced signature (EIP-191), and the recovered teeSignerAddress. A shared fixture must exercise a known-answer recovery so the client verifier and the broker signer are proven interoperable, not merely self-consistent. (An initial recovery KAT against a broker go-ethereum signature is already in client/sig; the full shared fixture is tracked with 0g-serving-broker #615.)

11. Replay & out of scope

Replay (client-side, per the design doc): the client SHOULD include a per-request nonce in a sealed field. Its hash is bound into the signed proof (§8), so replay of a captured proof fails the content-binding check. A server-side timestamp/nonce in the signed text is the belt-and-suspenders fix, tracked separately.

Out of scope for v1 (tracked):

  • Candidate scoring algorithm — the router's own internal concern, surfaced through its candidate API (§4.4); not a protocol contract.
  • A "strict" client mode that seals unknown fields by default (inverts the §5.1 trade-off for high-privacy users).
  • Sender-authenticated HPKE / PSK modes.
  • A server-side freshness field in the signed proof.

12. Where each invariant is enforced

Every rule above has a side that can prevent a violation and a side that can only detect one, and they are not the same side. The recurring mistake this table exists to stop is implementing a rule where it is convenient — the sender — and calling it done, when the threat is a counterparty that violates it on purpose and the only party who can refuse is the receiver.

The reading rule: the sender's check protects a conforming implementation from misconfiguring itself; the receiver's check is the one that holds against a counterparty that is not conforming. Where a rule protects one party from the other, that party's column is the load-bearing one.

Invariant Sender must refuse to build Receiver must refuse to accept
sealed set covers the request payload field (§5.1) yes yes — enclave (a third-party client is not obliged to check)
a conditionally required payload field present in the request is sealed — tools and tool_choice on chat and /v1/messages (§5.1), Anthropic's top-level system (§5.1), speech's filename / language / prompt (§5.3.2) yes yes — enclave (it is the half that sees a third-party client's envelope, and the field's presence in the cleartext half IS the violation). tools is the row's own cautionary case: it was a mere DEFAULT through most of v1, so a client that sealed only messages passed every check while the schemas went to the router — the failure this category exists to prevent, sitting in the category's own table
a frame seals its shape's content field, and a shape with none seals nothing (§7.2) yes yes — client (otherwise the content rides in the clear and Open still succeeds)
no frame carries another shape's content field in cleartext (§7.2) yes yes — client (this is what detects a mislabeled frame; the shape rules alone trust the sender's own label)
message_start.message.content is empty (§7.2) yes yes — client (message must stay cleartext for the token count, so nothing else would notice content placed there)
the frame discriminator is neither sealed nor unbound (§7.2) yes yes — client (an unbound discriminator makes every other shape check sender-controlled)
a protected cleartext field is neither sealed nor unbound, and smuggles no content — Anthropic's message (§7.2 rule 4) yes yes — client. Three failures, one field: a rewritten input_tokens verifies (as a rewritten top-level usage would), a SEALED message leaves the router billing zero, and content inside message.content rides in the clear. Only the client can refuse any of them, and only if the rule is anchored to the field rather than to one shape.
a shape's conditionally-sealed field is sealed when present — Anthropic's stop_sequence (§7.2 rule 6) yes yes — client (a field still in the received cleartext was never sealed, and no one else can tell)
a stream ends with a frame marked final — for Anthropic, message_stop OR error (§7.2) sealer marks it yes — client (§7 already: a missing final frame is a truncation). An enclave that treats only message_stop as terminal emits an error stream the client must then reject.
an unrecognized frame shape is refused (§7.2) yes yes — both (an unknown shape may carry content)
pinned cleartext field: correct value, not sealed, not unbound (§5.1/§7.1) yes yes — enclave
a CONDITIONALLY PINNED field is absent or holds a permitted value, and is neither sealed nor unbound — speech's stream (§5.3.3) yes yes — enclave, the only side that can decline: the alternative is emitting a stream whose frame shape this document does not define, which no receiver can then validate. Three traps. ABSENCE IS COMPLIANT, so an implementation that reuses the unconditional machinery rejects every conforming request. The rule must be a PERMITTED SET, not a list of refused values: the request is materialized back into multipart, where the renderings that read as true are an open set, and the first implementation's blacklist let "true" and 1 straight through. And the SEALED case is not the unconditional pin's: §6 reconstructs cleartext ∪ decrypted and forwards it, so a sealed stream: true reaches the upstream while the router sees a non-streaming request
on a multipart endpoint, a JSON-typed body is a valid envelope, and a multipart body carries no _e2ee part (§5.3.1) n/a — the sender chooses one shape and uses it yes — enclave, both halves. The second is the one that gets omitted: an envelope smuggled into a multipart part fails to parse as JSON, and "parse failure ⇒ not sealed" then forwards it in the clear. This is the only row whose violation leaks the payload with every other rule in this document still satisfied
response sealed set covers the generated content (§7) yes yes — client (otherwise the content rides in the clear and Open still succeeds)
usage not sealed (§7) yes client (loud either way: the router cannot bill)
usage not unbound (§5.2/§7.1) yes yes — client (otherwise a rewritten count verifies)
final frame carries the profile's billable cleartext — image: usage.output_images (§7.1) yes yes — client (a router cannot distinguish an omitted count from a zero, so it bills nothing and reports nothing)
final frame carries the profile's billable cleartext — speech: usage.seconds OR top-level duration, written by the enclave even when the upstream omitted it (§7.3) yes yes — client, and more load-bearing than the image row. A router with no usage block on this endpoint estimates from the transcript text, which sealing makes empty, so it falls through to a flat constant: the omission does not under-bill quietly, it bills a fabricated number nothing downstream can distinguish from a real one
final frame carries the profile's billable cleartext — embedding: usage.prompt_tokens, written by the enclave even when the upstream omitted it (§7.4) yes yes — client. Same shape as the speech row and for the same reason, one step worse: a router with no usage block here has nothing in the RESPONSE to estimate from (no text is echoed back), so it estimates from the request's input — which this profile seals — and floors to a constant. The enclave meanwhile holds the decrypted input and bills the provider accurately, so the two sides transact the same request at different prices. The sender's column is not sufficient on its own for the reason every row in this table exists: a third-party enclave that skips it produces a frame only the client can refuse
where a billable cleartext has ALTERNATIVE locators, a frame carrying both states the same value — speech (§7.3) yes — sealer, the only side that can compare both against the audio it measured client (it can detect the disagreement, but not which locator is honest). The sealer's column is load-bearing because the two readers differ: a client opening one locator while a router bills the other would silently transact on different numbers for one response
a conditionally sealed RESPONSE field is sealed when the frame carries it — speech: segments / words / language (§7.3) yes yes — client. A field still in the received cleartext was never sealed, and that is the receiver's only evidence: a router forwards such a frame unremarkably and the transcript rides through it in the clear. Same shape as the request side's conditional payload field, direction reversed
a required quantity's LOCATOR field is neither sealed nor unbound — speech's duration as well as its usage (§5.2 table) yes yes — client, on every frame. The floor list is the name usage, so nothing else in this document reaches a top-level duration: derive the rule from the profile's locators rather than from names. The SEALED half is not redundant with the presence check once a quantity has alternatives — see §5.2
async-image: _bind is sealed in the request, and the result is sealed in mode_psk keyed by it (§7.5.1, §7.6) yes — sender attaches and seals it; enclave strips it before the upstream and seals the result with it, never in base mode yes — enclave refuses a request without it; yes — sender opens the result only in mode_psk with the value derived from its eph_priv. The sender's half is the one that stops a router-submitted request carrying the victim's key: in base mode, Open and §8 both pass on that frame
synchronous profiles: a request carrying _bind seals it, and the response to it is sealed in mode_psk keyed by it (§7.6) yes — sender seals it; enclave strips it before the upstream and keys the response context with it yes — enclave refuses a cleartext _bind; yes — sender opens only in mode_psk with the value it derived, and refuses the response at the first frame otherwise. The sender's half is the one that stops a router-substituted stream, and the only one that stops it before delivery
async-image: result frame is completed, names the polled broker job id, and frees nothing beyond model / x_0g_trace (§7.5.2) yes — enclave declares the unbound set at completion yes — sender. jobId in the AAD is what refuses a replay's result on the original job; an unbound jobId would make that check sender-controlled
async-image: no upstream error text in a sealed job's failed body (§7.5.2) yes — enclave, the only side holding the text sender replaces an unknown errorMessage, but cannot tell a code from text that happens to look like one
async-image: the key segment of user_job_id never reaches the router or a log (§7.5.3) yes — sender n/a — no other party sees it
decrypted keys == declared sealed_fields (§5.1/§6) by construction yes
no sealed/cleartext collision (§5.1) by construction yes
envelope v / kem_id supported (§9) by construction yes
signer_addr is this enclave (§4.4/§6) client pins yes — enclave
final frame received (§7) sealer emits yes — client (its absence is a truncation). A non-streaming response is one frame, so the opener requires final on it directly. For a stream only the caller's read loop knows the stream ended, so that half is the caller's and cannot be delegated to a frame-at-a-time opener.
a receive-side check is not gated on a sender-controlled value (§7.1/§12) n/a yes — client. Obligations that fall due on the final frame are reachable only if final itself is checked; final is chosen by the sealer, so a check hung on it is a check the sender can decline.
frame order (§7) sealer sequence yes — client (the AEAD sequence enforces it)
response is sealed at all n/a yes — client (a frame with no _e2ee is not a sealed response)

A new rule added to this spec MUST fill in both columns explicitly, including when the honest entry is "cannot be checked here, and here is why".