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-textis covered (profilespeech, §5.3.2, §7.3);image-editingis 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).
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.
- 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.
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 usesmode_psk(0x01), keyed by a value only the sender and the enclave hold (§7.6). No other mode is used. - Request confidentiality: HPKE
Sealof the sealed-field object to the provider enc key; cleartext fields are bound as AAD (§5, §6). - Response confidentiality: a fresh HPKE
Sealfrom 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_signdigest (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).
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.)
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 byversion.
key_id = SHA-256(enc_pub)[0:8] (8 bytes, base64url on the wire). Lets the
enclave select the right key across rotations.
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:
- Verify the quote — genuine TDX + expected measurement (trust model in
../docs/design/router-e2e.md). - Extract
enc_pub+signer_addrfromreport_data, checkversion. - Confirm
signer_addrequals the provider's on-chainteeSignerAddress.
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.
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_fieldsstays cleartext. client_eph_pubis where the enclave seals the response (§7). It lives in the AAD-protected_e2ee, so the router cannot swap it (that would breakOpen).signer_addris the pinned provider's TEE signer address (§4.4); the enclave rejects a request whosesigner_addr!= its ownteeSignerAddress.unbound_fields(optional, omitted when empty) lists cleartext fields excluded from the AAD — intermediary-mutable metadata; see §5.2.
- 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,toolsandtool_choice— which for this profile is exactly its payload fields,messagesalways 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_fieldsomits it MUST be refused, by the sender at seal time and by the enclave at open time — before any decryption, since the check reads onlysealed_fields(§12, first row). Leavingmessagescleartext 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. toolswas 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: asealed_fieldsof just["messages"]satisfied every rule in this document and handed the schemas to the router.tool_choiceis payload fortools' 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 malformedtool_choiceis refused by the upstream rather than at the router, the same tradetoolsalready makes. The enclave is unaffected: it holds the key, andOpenRequestForhands it the reconstructed request.- A client MAY seal additional fields (e.g.
metadata,user); it declares them insealed_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 nosystem. A sender applying the default MUST therefore narrow it to the fields the request actually carries, andsealed_fieldsnames 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 equalsealed_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.
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, whereurlhas 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
speechprofile'sresponse_format, wheretext/srt/vttreturn a body that is not a JSON object, so there is no frame to seal and noaadfor 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_fieldsnames 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_fieldsomits 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_fieldsomits 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 themessagesone above, and it is what makes the requirement enforceable rather than merely advisory.
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
_e2eeand is therefore bound — an attacker cannot enlarge it (that changes the AAD andOpenfails), 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/
nullmeans exclude nothing. -
It MUST be disjoint from
sealed_fieldsand MUST NOT name_e2ee. -
Values in unbound fields are unauthenticated: nothing may trust them (see §8 — the signature covers only non-unbound content).
-
HPKE
infoMUST be"0g-pc/v1/seal"(ASCII), domain-separating this usage.
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.
filenametravels 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.
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.
/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:
- Two billable-cleartext locators, as alternatives.
verbose_jsonresponses commonly carry nousageblock at all, only a top-levelduration, so §7.3's required cleartext is satisfied by either. - Conditional sealing on the response side.
segments[]carries the transcript per segment,words[]per word, and the response'slanguageis inferred from the audio — all payload. So the response sealed set is not the constant["text"]but "text, plus each ofsegments/words/languagethe 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".
/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": 1both 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.
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).
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
usageis authenticated to the client/auditor without decryptingchoices— 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":"<...>"}}
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_imagesis the billable count and MUST be cleartext. The router bills per delivered image and cannot count a sealeddata[]. The enclave writes the count of images it actually delivered (not the requestedn— 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 (
usageis 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 explicit0is a valid count and is not an omission;nullis 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
usagebecause that is where a quantity billed on belongs, and is namedoutput_imagesrather thanimagesfor two reasons:usageis an OpenAI-defined object (a token-billed image model such asgpt-image-1populates it withinput_tokens/output_tokens/input_tokens_details, while a per-image model such asdall-e-3omits 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 bareimageswould read ambiguously. Theinput_/output_prefixes are OpenAI's own convention in this object. Any token fields the model reports are preserved alongside it; onlyoutput_imagesis written by the enclave. -
A sealed image request MUST carry an explicit
response_format: "b64_json". Not "must not beurl" — must be present and must beb64_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_formatdefaults tourlfor the DALL·E family (onlygpt-image-1always returnsb64_json). So an omitted field is a request to publish the images in the clear, spelled as silence — a rule phrased as "rejecturl" 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.SealRequestFordoes), and MUST NOT listresponse_formatinunbound_fields— an unbound pin binds nothing after the seal (§5.1). An enclave MUST reject a violating request it receives, rather than silently downgrading tob64_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.
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.
-
A shape with a content field MUST seal it. As §7 elsewhere.
-
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'smessageholds the input token count the router bills on, so a sealer free to seal extra on a sequencing frame could make the response unbillable. -
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 claimsmessage_stopand puts the answer in a cleartextdeltasatisfies "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. -
messageis a PROTECTED cleartext field: it may not be sealed, may not beunbound, andmessage.contentmust 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 fixesmessage.contentto[], 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 wheremessagelegitimately appears — the emptiness rule held on exactly one shape, and apingormessage_stopframe could carrymessage: {"content": [...]}untouched: rule 3 does not namemessage, 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. -
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. -
A shape MUST seal
stop_sequencewhenever the frame carries it. It is the custom stop string the CLIENT supplied instop_sequences, echoed back — the caller's own input, not model output. The streaming path seals it already (it lives insidemessage_delta'sdelta); 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 addsstop_sequencesto 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_reasonis 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 thedeltaobject 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.
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 ofsegments,wordsandlanguagethat the response carries. These are conditionally sealed on the response side, exactly as the top-levelsystemis 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 andwords[]per word — the same content astext, cut differently — andlanguagehere 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_jsoncarriessegmentsand, 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.secondsOR the top-levelduration. This is the §7.1usage.output_imagesrule with two alternatives and a sharper failure mode.The alternation is not laxity: it is the two shapes upstreams actually emit.
jsonresponses carry{"type": "duration", "seconds": N};verbose_jsonresponses commonly carry nousageblock at all and report the audio length only as a top-levelduration. 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.Nis 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 explicit0is a valid quantity;null, or neither locator present, is an omission and is not a zero.A locator's containing field being
nullis an ERROR, not an unused alternative."usage": nullis the block being present and not being an object, so the frame is malformed and MUST be refused — distinct fromusagebeing 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 JSONnullinto a map succeeds and yields an empty one, so"usage": nullreads as "the key is not in there" and the requirement is then satisfied bydurationalone.nullis 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.
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
usageand forbid declaring it unbound. Neither requires the field to exist. A frame carrying nousageat 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.
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.
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 whosesealed_fieldsomit it (§5.1 rules). - Enclave. After Open, the enclave MUST remove
_bindbefore the upstream call (it is protocol metadata, not a model parameter), keep it with the job, and seal the result inmode_pskkeyed by it (§7.6). It MUST NOT seal an async-image result in base mode. - Receiver. The receiver recomputes
bindfrom theeph_privit holds and opens the result inmode_pskwith 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 fromeph_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.
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
datais the provider's image response, exactly as the cleartext poll would embed it, inmode_pskkeyed by the request's binding (§7.5.1). After Open the receiver returns the poll envelope in its ordinary shape. usage.output_imagesis the billable count, under the same rules as §7.1.unbound_fieldsis 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 injectsx_0g_traceon completed polls, so a frame without it fails to open on the first traced poll.modelis unbound as on every sealed response (§5.2). A receiver MUST refuse a frame that declares any other field unbound:jobIdandstatusare what its checks key off.- Which job. The receiver MUST require
status == "completed"andjobIdequal 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.
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
jobIdwithuser_job_idin 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.
- Submit pinning. The sender seals to one attested enclave, so the submit
MUST pin that provider (
X-0G-Provider-Address, plusX-0G-Provider-Identitywhen the candidate has one) with fallbacks disabled. It MUST check that the202'sprovider_addressequals 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.
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
_bindto the request. When it does, it MUST seal it. A request carrying_bindin 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
_bindMUST open the response in mode_psk with thebindit derives fromeph_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.
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:
- 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). - Recompute the content binding —
reqH/respHMUST equal the client's own hashes, computed by mode (below). - Recover the signer —
addr = ecrecover(EIP191(text), signature)(ECDSA secp256k1, personal_sign). - Accept only if
addr == on-chain teeSignerAddress— the quote-bound signer, grounded on-chain (§4.4 / hop 5); never the self-reportedsigning_address(a hint only).
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.)
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.
_e2ee.v, the responsev, thereport_dataversion, 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/
inforule, or a newreport_datalayout 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_fieldsis 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.
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.)
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.
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".