Node API
This document specifies the ENC Node API — the HTTP, WebSocket, and webhook surface every conforming node exposes. It covers the high-level surface and Session tokens, the REST endpoints and Filter query DSL, the WebSocket streaming subscription protocol, the proof retrieval endpoints, the push / notify and webhook delivery semantics, the wire-level handling of encrypted payloads, and the full error catalog.
Table of Contents
- Overview
- Session
- Enclave API
- Filter
- WebSocket API
- Proof Retrieval API
- Webhook Delivery
- Registry DataView API
- Push/Notify
- Encryption
- Error Codes
- Error Response Format
Overview
Base URL
https://<node_host>/Content Type
All requests and responses use application/json.
Authentication
| Operation | Method |
|---|---|
| Commit | Schnorr or ECDSA signature over commit hash (per alg; see Signature Schemes) |
| Query | Session token (see Session) |
| Pull | Session token |
| WebSocket Query | Session token |
| WebSocket Commit | Schnorr or ECDSA signature (per alg) |
Endpoints Summary
Enclave API (all enclaves):| Method | Path | Description |
|---|---|---|
| POST | / | Submit commit, query, or pull request |
| WS | / | Real-time subscriptions |
| GET | /enclaves/:id/snapshot | Download complete enclave state (.enc) — see Snapshot Endpoints |
| POST | /enclaves/:id/restore | Bootstrap enclave from a .enc payload — see Snapshot Endpoints |
| Method | Path | Description |
|---|---|---|
| POST | /create-enclave | Legacy self-serve enclave bootstrap endpoint; see Legacy /create-enclave reference. |
Deployments can omit /create-enclave when bootstrap is gated externally (e.g., a hosted node service like impl-cloud that pre-provisions enclaves before the node accepts traffic). The detailed legacy section specifies the absence response.
Request types: Commit, Query, Pull
Proof Retrieval API:| Method | Path | Access | Description |
|---|---|---|---|
| GET | /:enclave/sth | Public | Current signed tree head |
| GET | /:enclave/consistency | Public | CT consistency proof |
| POST | /inclusion | R | CT inclusion proof |
| POST | /bundle | R | Bundle membership proof |
| POST | /state | R | SMT state proof |
| POST | /state-batch | R | Batched SMT state proofs |
| Method | Path | Description |
|---|---|---|
| GET | /nodes/:seq_pub | Resolve node by public key |
| GET | /enclaves/:enclave_id | Resolve enclave → enclave record + hosting node |
| GET | /identity/:id_pub | Resolve identity by public key |
Session
Session tokens provide stateless authentication for queries. An identity authorizes a session key in one of two ways: the Schnorr-algebraic token below (default), or a delegated token authorized by a recoverable ECDSA signature (for identities that sign with ECDSA; see Delegated Session).
Token Format
136 hex characters = 68 bytes
Bytes 0-31: r (Schnorr signature R value)
Bytes 32-63: session_pub (x-only public key)
Bytes 64-67: expires (big-endian uint32, Unix seconds)Client Derivation
1. expires = now + duration (max 7200 seconds)
2. message = "enc:session:" || be32(expires)
3. sig = schnorr_sign(sha256(message), id_priv)
4. r = sig[0:32]
5. s = sig[32:64]
6. session_priv = s
7. session_pub = point(s)
8. session = hex(r || session_pub || be32(expires))Node Verification
O(1) EC math — no signature verification.
1. Parse: r, session_pub, expires from token
2. Check: expires > now - 60 (allow 60s clock skew)
3. Check: expires ≤ now + 7200 + 60 (allow 60s clock skew)
4. message = "enc:session:" || be32(expires)
5. expected = r + sha256(r || from || message) * from
6. Verify: session_pub == expectedClock skew tolerance (±60 seconds) allows clients with slightly-off clocks to connect.
Curve Parameters:All EC arithmetic uses secp256k1. The curve order is:
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141All scalar operations (addition, multiplication) are performed modulo n.
Delegated Session (ECDSA-authorized)
Identities that sign with ECDSA rather than Schnorr (see Signature Schemes) cannot produce the Schnorr-algebraic token above, because session_pub = r + e·from requires a Schnorr signature by the identity key. Such an identity MAY instead authorize a fresh ephemeral session key with a recoverable ECDSA signature.
1. expires = now + duration (max 7200 seconds)
2. (session_priv, session_pub) = fresh ephemeral secp256k1 keypair
3. auth_msg = sha256("enc:session:delegate:" || session_pub || be32(expires))
4. session_auth = ecdsa_sign_recoverable(auth_msg, id_priv) # { sig: r||s compact, recovery }
5. session = hex(0x00*32 || session_pub || be32(expires)) # the r slot is unused (32 zero bytes)session_auth is carried alongside the token in the request payload (it does not fit the 68-byte token): the Query/Pull encrypted content gains an optional session_auth field.
1. Parse: session_pub, expires from token; read session_auth from the request payload
2. Check expiry (same ±60s skew bounds as the Schnorr session)
3. auth_msg = sha256("enc:session:delegate:" || session_pub || be32(expires))
4. recovered = ecdsa_recover(auth_msg, session_auth.sig, session_auth.recovery)
5. Verify: x_only(recovered) == fromThe ephemeral session_pub feeds Signer Derivation and ECDH exactly as a Schnorr session does — only the authorization differs. The identity key signs once to authorize the session key; the long-term key is not used again for that connection.
| Schnorr session | Delegated session | |
|---|---|---|
| Identity binding | intrinsic (token is a Schnorr signature by from) | explicit (ecrecover(session_auth) == from) |
| Recoverable from token alone | no | yes (via ecrecover) |
| Self-contained in the 68-byte token | yes | no (auth travels with the request) |
| Requires Schnorr signing | yes | no (ECDSA only) |
Signer Derivation
Per-node signer for ECDH.
t = sha256(session_pub || seq_pub || enclave)
signer_priv = session_priv + t (mod n)
signer_pub = session_pub + t * GThe enclave ID is included in the signer derivation so that the same session_pub produces different signer keys for different enclaves. This provides per-enclave key isolation:
- Session token is reusable across enclaves (same
session_pub) - Derived encryption key (via
signer_pub) is enclave-specific - A compromised signer key in Enclave A cannot decrypt messages for Enclave B
The isolation provides defense-in-depth.
Security Properties:| Property | Protected? | Reason |
|---|---|---|
| Cross-session reuse | ✓ | Different session_pub → different t → different signer |
| Cross-enclave reuse | ✓ | Different enclave → different t → different signer |
| Cross-node reuse | ✓ | Different seq_pub → different t → different signer |
| Same session + enclave | Same signer | Intentional — enables session continuity |
No additional replay protection is needed; the derivation inputs guarantee uniqueness.
Session Properties
| Property | Value |
|---|---|
| Max expiry | 7200 seconds (2 hours) |
| Timestamp unit | Seconds (for uint32 compactness; API timestamps use milliseconds) |
| Reusable | Yes, until expiry |
| Per-node signer | Yes (different ECDH per node) |
| Multi-key | One connection, multiple sessions |
Hook System (server-side policy layer)
Nodes admit commits to the protocol layer via a configurable hook chain.
A hook is a policy gate that examines an incoming commit and either lets
it through (pass) or refuses admission (reject). Hooks MUST NOT
transform commits — they only side-effect (audit, telemetry, rate-counter
increment) or reject. The protocol layer's state machine sees only the
admitted commits.
Policy layer (hooks) ─── side effects + reject decisions
│
▼ (admitted commits, unchanged)
Protocol layer (kernel) ── deterministic state machine, CT log, SMT rootsThe hook orthogonality theorem (Enc.Core.Hooks.Determinism.replay_invariant_under_hook_swap)
establishes the release rule: two deployments with different hook configurations but identical admitted-commit sets MUST produce IDENTICAL protocol state. The kernel's
correctness theorems (sharding, CT log, composition, rebalance, jump-hash bound,
Telegram-scale runnability) all apply for ANY hook configuration. Hooks are
deployment policy; the protocol layer is policy-agnostic.
Hook chain semantics
- Hooks compose in deployer-specified order.
- Any
rejectshort-circuits the chain. The commit is refused. - All
pass→ commit is admitted to the protocol layer. - Empty chain → admit everything (no policy; not safe for production).
Standard starter hooks
| Hook | Trigger | Rejects when |
|---|---|---|
| Bearer | Authorization: Bearer <SHARED_SECRET> header | header missing or token != configured secret |
| ProvisionerSig | X-Provisioner-Sig + X-Provisioner-Ts headers | sig invalid, ts skewed, or signer not allowlisted |
| ParseCommitSigner | (no headers — reads commit.from_pub) | from_pub not in allowlist |
| RateLimitByPub | per-pubkey counter | rate exceeded |
Implementations can ship additional hooks (CF Access JWT, API key allowlist,
IP allowlist, etc.) as opt-in plugins. The intended promotion path for each
hook plugin is a formal Lean specification in Enc.Core.Hooks.<name> and a
claim sidecar in claims/prose/node/node-api.claims.yaml (or per-plugin file).
Verify-once invariant
For Commit-shaped requests, the hook layer avoids verifying the commit's
Schnorr signature; the kernel verifies it as part of normal commit
processing. A ParseCommitSigner hook that does an allowlist check on
commit.from_pub (without re-verifying the sig) keeps the verification
cost at ONE signature operation per request, not two.
For non-commit requests (admin endpoints with no downstream kernel verification), full cryptographic auth via Bearer / ProvisionerSig / JWT is appropriate.
Deprecated: /create-enclave
The /create-enclave endpoint is DEPRECATED as of this revision.
Hosted node services and self-serve deploys use either of these bootstrap
paths:
-
Hosted gating — the hosting plane (e.g.,
impl-cloud) accepts user bootstrap requests, signs the manifest commit itself, and POSTs the resulting commit toPOST /(the standard multiplexed endpoint). The node treats this as any other commit; a Bearer hook authorizes the hosting plane's API access. -
Self-signed bootstrap — the operator signs a manifest commit offline and POSTs it to
POST /directly.
Backward-compatible implementations can retain a /create-enclave endpoint,
but new deployments use POST / plus a hook chain for bootstrap admission.
The hook chain at POST / covers all admission policies that
/create-enclave previously gated.
Legacy /create-enclave reference (kept for backward compat)
Some deployments expose a self-serve endpoint that creates a fresh enclave by signing a manifest commit. The endpoint is OPTIONAL: implementations choose whether to include it based on their deployment model.
When to include/create-enclave:
- Self-serve nodes where any caller may instantiate a new enclave.
- Reference implementations used for protocol conformance testing.
- Local-dev / open-test infrastructure.
/create-enclave:
- Hosted node services (e.g.,
impl-cloud) that gate enclave creation externally — bootstrap is performed via the hosting plane's admin API, not the per-node HTTP surface. - Single-tenant deployments where the enclave set is fixed at deploy time.
- Compliance regimes that require pre-provisioning + per-enclave KYC.
Deployments that omit the endpoint MUST return 404 Not Found so
callers can detect the absence with a single round-trip.
POST /create-enclave (when supported)
Create a fresh enclave by submitting a manifest commit. The node
generates the enclave_id deterministically from the manifest content
and signs the bootstrap event with its node key
(NODE_PRIVATE_KEY).
{
"manifest": {
"RBAC": { "use_temp": "none", "schema": [ ... ] },
"<other manifest fields>": ...
}
}{
"ok": true,
"enclave_id": "<64 hex chars>",
"head": { "<sealed head>": ... }
}Response (omitted endpoint): 404 Not Found
Response (errors): Standard error envelope (see Error Codes).
Semantics:- The node MUST verify the manifest is well-formed before signing.
- The node MUST sign the bootstrap commit with the configured
NODE_PRIVATE_KEY. - The resulting
enclave_idMUST be deterministic from the manifest content (so the same manifest from two callers produces the same enclave_id and idempotent collisions are detectable). - Idempotent reposts of the same manifest MUST return the existing
enclave's
head, not create a duplicate.
Enclave API
POST / (Commit)
Submit a commit to the enclave.
Detection: Request contains exp field.
{
"hash": "<hex64>",
"enclave": "<hex64>",
"from": "<hex64>",
"type": "<string>",
"content": "<string>",
"content_hash": "<hex64>",
"exp": 1706000000000,
"tags": [["key", "value"]],
"sig": "<hex128>"
}| Field | Type | Required | Description |
|---|---|---|---|
| hash | hex64 | Yes | CBOR hash of commit (see spec.md) |
| enclave | hex64 | Yes | Target enclave ID |
| from | hex64 | Yes | Sender's identity public key |
| type | string | Yes | Event type |
| content | string | Yes | Event content as a UTF-8 string. Binary MUST be base64-encoded (see spec.md §Content). The node MUST verify sha256(utf8_bytes(content)) == content_hash (next field) and reject with CONTENT_HASH_MISMATCH if not. Empty payloads use "". |
| content_hash | hex64 | Yes | sha256(utf8_bytes(content)). Bound in the commit signature via the CBOR pre-image; the node enforces the match against content. |
| exp | uint | Yes | Expiration timestamp (Unix milliseconds) |
| tags | array | No | Array of [key, value] pairs |
| alg | string | No | Signature scheme: "schnorr" (default if absent) or "ecdsa" (see Signature Schemes) |
| sig | hex128 | Yes | Signature over hash, per alg |
content_hash: The client MUST send content_hash alongside content (it's a commit field — see spec.md §Commit Structure).The node MUST recompute expected = sha256(utf8_bytes(content)) and reject the commit with CONTENT_HASH_MISMATCH if expected ≠ content_hash.See spec.md §Commit Hash Construction for the rationale and implementation status table.
Response (200 OK): Receipt
{
"type": "Receipt",
"id": "<hex64>",
"hash": "<hex64>",
"timestamp": 1706000000000,
"sequencer": "<hex64>",
"seq": 42,
"sig": "<hex128>",
"seq_sig": "<hex128>"
}| Field | Type | Description |
|---|---|---|
| type | string | Always "Receipt" |
| id | hex64 | Event ID |
| hash | hex64 | Original commit hash |
| timestamp | uint | Sequencer timestamp (Unix milliseconds) — recorded when sequencer finalizes the event, not client submission time |
| sequencer | hex64 | Sequencer public key |
| seq | uint | Sequence number |
| sig | hex128 | Client's signature (from commit) |
| seq_sig | hex128 | Sequencer's signature over event |
Note: Receipt omits enclave for privacy — client already knows which enclave it submitted to.
| Code | HTTP | Description |
|---|---|---|
INVALID_COMMIT | 400 | Malformed commit structure |
INVALID_HASH | 400 | Hash doesn't match CBOR encoding |
INVALID_SIGNATURE | 400 | Signature verification failed |
EXPIRED | 400 | exp < current time |
DUPLICATE | 409 | Commit hash already processed |
UNAUTHORIZED | 403 | Insufficient RBAC permissions |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
ENCLAVE_PAUSED | 403 | Enclave is paused |
ENCLAVE_TERMINATED | 410 | Enclave is terminated |
RATE_LIMITED | 429 | Too many requests |
POST / (Query)
Query events from the enclave.
Detection: Request contains type: "Query" field.
{
"type": "Query",
"enclave": "<hex64>",
"from": "<hex64>",
"content": "<encrypted>"
}| Field | Type | Required | Description |
|---|---|---|---|
| type | string | Yes | Must be "Query" |
| enclave | hex64 | Yes | Target enclave ID (plaintext for routing) |
| from | hex64 | Yes | Requester's identity public key |
| content | string | Yes | Encrypted payload (see Encryption) |
{
"session": "<hex136>",
"filter": { ... }
}| Field | Type | Required | Description |
|---|---|---|---|
| session | hex136 | Yes | Session token (see Session) |
| session_auth | object | No | ECDSA authorization { sig, recovery } for a delegated session (see Delegated Session) |
| filter | object | Yes | Query filter (see Filter) |
Note: enclave is plaintext for routing — node needs it before decryption.
{
"type": "Response",
"content": "<encrypted>"
}{
"events": [
{ "event": Event, "status": "active" },
{ "event": Event, "status": "updated", "updated_by": "<hex64>" },
...
]
}| Field | Description |
|---|---|
| event | The event object |
| status | "active" — event is current; "updated" — superseded by Update event |
| updated_by | (Present when status: "updated") Event ID of the superseding Update event |
Note: Deleted events are NOT returned. To query deleted event IDs, use the SMT Event Status proof.
Errors:| Code | HTTP | Description |
|---|---|---|
INVALID_QUERY | 400 | Malformed query structure |
INVALID_SESSION | 400 | Session token verification failed |
SESSION_EXPIRED | 401 | Session token expired |
DECRYPT_FAILED | 400 | Cannot decrypt content |
INVALID_FILTER | 400 | Malformed filter |
UNAUTHORIZED | 403 | No read permission |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
RATE_LIMITED | 429 | Too many requests |
Filter
Query filter for event retrieval.
Structure
{
"id": "<hex64> | [<hex64>, ...]",
"seq": "<uint> | [<uint>, ...] | Range",
"type": "<string> | [<string>, ...]",
"from": "<hex64> | [<hex64>, ...]",
"tags": { "<tag_name>": "<value> | [<value>, ...] | true" },
"timestamp": "Range (Unix ms)",
"limit": 100,
"reverse": false
}All fields are optional. Omitted field = no filter (match all).
Tags Filter:The tags field filters events by tag presence or value:
{ "r": "abc123." }— events withrtag matching value{ "r": ["abc123.", "def456."] }— events withrtag matching any value{ "auto-delete": true }— events withauto-deletetag (any value)
Events are sorted by sequence number ascending unless reverse: true. This is the canonical enclave order.
Range
{
"start_at": 100, // >= 100
"start_after": 100, // > 100
"end_at": 200, // <= 200
"end_before": 200 // < 200
}Semantics
| Pattern | Meaning |
|---|---|
| Top-level fields | AND |
| Array values | OR |
| Omitted field | Match all |
Limits
| Field | Max |
|---|---|
id[] | 100 |
seq[] | 100 |
type[] | 20 |
from[] | 100 |
tags keys | 10 |
tags values per key | 20 |
limit | 1000 |
Examples
By type:{ "type": "message" }{ "type": "message", "from": ["abc...", "def..."] }{ "timestamp": { "start_at": 1704067200000, "end_before": 1704153600000 } }{ "seq": { "start_after": 150 }, "limit": 100 }{ "type": "message", "reverse": true, "limit": 20 }WebSocket API
Real-time pub/sub for event subscriptions.
Endpoint: wss://<node_host>/
Subscription is automatic: First valid Query on a connection creates a subscription. The node assigns a sub_id — or, if the Query carries one, adopts the caller-supplied value (any UTF-8 string) — and begins streaming events.
Connection Model
| Direction | Type | Description |
|---|---|---|
| C → N | Query | First valid Query subscribes; node adopts a caller-supplied sub_id or assigns one |
| C ← N | Event | Stored events (encrypted) |
| C ← N | EOSE | End of stored events |
| C ← N | Event | Live updates (encrypted) |
| C → N | Commit | Write event |
| C ← N | Receipt | Write success |
| C ← N | Error | Write error |
| C → N | Close | Unsubscribe from subscription |
| C ← N | Closed | Subscription terminated |
| C ← N | Notice | Informational message |
Client → Node
| Message | Format | Description |
|---|---|---|
| Query | Same as POST / (Query), optional sub_id | First valid Query subscribes; node adopts a caller-supplied sub_id or assigns one |
| Commit | Same as POST / (Commit) | Write event, returns Receipt |
| Close | { "type": "Close", "sub_id": "<string>" } | Unsubscribe from subscription |
Note: Close unsubscribes from a single subscription. To close the entire WebSocket connection, close the WebSocket transport directly. Closing the transport terminates all active subscriptions.
Subscription Identifiers (sub_id)
Every subscription on a connection is keyed by a sub_id, which the node echoes on every frame it sends for that subscription (Event, EOSE, Closed). A sub_id is set one of two ways:
- Node-assigned (default): if a
Querycarries nosub_id, the node generates a unique one and tags the subscription's frames with it. - Caller-supplied: if a
Queryincludes asub_id(a non-empty string), the node adopts it verbatim as the subscription key. The caller is responsible for keeping its supplied ids unique within the connection.
Caller-supplied ids let a client carry an arbitrary number of independent subscriptions over a single connection and demultiplex incoming frames by sub_id without tracking a node-assigned mapping. They are also the wire-level foundation of the aggregator pattern described below.
Subscribe Replay Semantics (no silent truncation)
Query-as-subscribe replays stored events to the client before transitioning to live. The replay window is fully determined by the caller's filter cursor, NOT by a node-side default page size:
- With
seq.start_after = c: the node MUST replay every stored event withseq > ccontiguously, then transition to live. The transition is signalled byEOSEand the live stream resumes from the first event committed after the last replayedseq. There MUST NOT be a gap. - Without a
seqcursor: the subscription is live-only. The node MUST NOT replay an oldest-N window of stored events. Catch-up history is obtained via a separatePOST / (Query)request with explicit pagination, then the client opens a subscribe withseq.start_after = <last_seen_seq>to attach the live stream contiguously.
Equivalently: limit is a Pull/Query pagination control, not a subscribe-replay cap. A node MUST NOT apply a default limit to subscribe-replay that would produce a non-contiguous "oldest-N then live" stream.
The rule exists because silent truncation is the worst failure mode: a {} subscribe that returns the oldest 100 stored events and then transitions to live drops the entire middle range and is impossible for the client to detect from frame contents alone. A node that needs to bound subscribe cost for DoS reasons MUST do so by:
- Rejecting the subscribe with an explicit
Closedreason (e.g.,subscribe_replay_too_largewith a hint to Pull history in chunks); OR - Streaming events contiguously and asynchronously without buffering them all in memory; OR
- Treating the absent-cursor case as live-only per the rule above.
A node MUST NOT silently keep the oldest-N events and discard everything since.
Client cache reconciliation. A client that caches events locally MUST NOT assume subscribe-replay re-delivers state-mutating events such as Delete or Update. On reconnect / rejoin, the client MUST reconcile its cached events against the authoritative event_status SMT entries (the 0x01 namespace, per smt.md §Event Status), and treat any cached event whose authoritative status is deleted or updated as no longer active — regardless of whether the corresponding Delete / Update event was observed in the subscribe stream. Caching clients on busy enclaves WILL, by the no-truncation rule above, sometimes miss the explicit Delete event on the live stream; reconciliation against event_status is what closes the resurrection hole.
Aggregator Pattern (Connection Collapse)
The problem. A client watching its DM enclave plus N group enclaves needs N+1 WebSocket connections to the node — one per enclave (because each enclave is its own subscription target, and platforms like Cloudflare bind an accepted WS to one Durable Object instance per enclave). A 100-member group with every member online means every other member opens a socket into that enclave's DO; connection count scales as O(clients × enclaves_per_client).
The aggregator. A WebSocket aggregator (a "hub") sits between clients and the node and collapses that fan-out:
Without aggregator With aggregator
────────────────── ───────────────
client A ─┬─► node:enclave_1 client A ─┐
├─► node:enclave_2 │
└─► node:enclave_3 ├─► hub ─┬─► node:enclave_1
│ ├─► node:enclave_2
client B ─┬─► node:enclave_1 client B ─┤ ├─► node:enclave_3
├─► node:enclave_2 │ └─► node:enclave_4
└─► node:enclave_4 │
client C ─┘
(6 client→node sockets)
(3 client→hub sockets
+ 4 hub→node upstream sockets)- Each client opens ONE WebSocket to the hub, regardless of how many enclaves it watches. All of that client's subscriptions are multiplexed on it via caller-supplied
sub_ids. - The hub opens ONE upstream WebSocket per enclave (refcounted across all its clients of that enclave). When the last client of an enclave unsubscribes, the upstream is closed; when the next client subscribes, it reopens.
- Total connections drop from
O(clients × enclaves_per_client)(without) toO(clients) + O(distinct live enclaves)(with).
Why this is wire-compatible. The hub is a pure sub_id router:
- It forwards
Queryframes verbatim —{session, filter}stays opaque, the node verifies each client's session and enforces per-client RBAC. The hub is never an authorization point; it cannot read encrypted event payloads and does not need to. - It rewrites the
sub_idon the way in (to a hub-global id unique on the upstream socket) and back to the client's id on the way out — so two clients can both usesub_id = "s1"without colliding. - It honors
Close { sub_id }per subscription, decrementing the upstream refcount and closing the upstream when refcount = 0.
The node sees only a normal multiplexed-sub_id client and applies its normal authorization rules per Query. No new opcodes; no auth model changes.
Wire-level requirements the aggregator pattern imposes on the node. These are normative — an aggregator built against a node missing any of them silently misroutes subscriptions:
Queryaccepts a caller-suppliedsub_id(used verbatim) and echoes it onEvent/EOSE/Closed/Errorframes. (Defined above under Caller-supplied.)Close { sub_id }removes only the named subscription, leaving other subscriptions on the same connection alive.- Hibernation preserves ALL subscriptions per connection. When a node hibernates an idle WebSocket (e.g., Cloudflare Durable Object
acceptWebSocket()) and restores it on the next inbound message, ALL of the connection's active subscriptions MUST be restored — not just the last-touched one. Restoring only the last drops every other client routed through the aggregator; this is a wire-incompatible regression. Implementations using attachment-style persistence (e.g.,ws.serializeAttachment()) MUST write the FULL per-connection subscription set, not per-subscription. tagsround-trip losslessly through storage. Plugins (dm:sentwith["to", ...], cross-enclavenoticewith["enclave_id", ...]) depend on tags surviving backfill from the event store, not just live broadcast. (Normative requirement is inspec.md§Tags.)
Items 1, 2, and 4 are also true for direct client→node use; item 3 only matters under aggregation (a single-subscription connection trivially preserves its only sub). All four are checked by tools/conformance/run-wire.mjs against the Lean reference; sibling impls get equivalent CI coverage.
WebSocket Heartbeats
The wire protocol defines a minimal ping / pong heartbeat to keep connections alive across proxy / edge idle timeouts (notably Cloudflare's ~100s outbound WS idle close, which does not always surface as a clean close event):
| Direction | Frame | Body |
|---|---|---|
| C → N | text frame | ping |
| N → C | text frame | pong |
| N → C | text frame | ping |
| C → N | text frame | pong |
Wire format: plain-text ping / pong. Not JSON. Not a typed message envelope. The 4-byte (ping) / 4-byte (pong) payloads are kept short so they remain distinguishable from any JSON message (every JSON message begins with {).
Cadence: SHOULD send a ping every 25 seconds of idle, with a 10-second pong deadline. On missed pong, the sender SHOULD close the connection and reconnect.
Symmetry: both directions independently initiate pings. A client pings the node to detect silent edge timeouts; the node (or an intermediary aggregator forwarding to an upstream node) pings its peer for the same reason. The response handler is the same on both sides: reply pong to any inbound ping.
Aggregator forwarding: an intermediary multiplexer forwards a client's ping to its upstream and the upstream's pong back, so a single missed pong on EITHER side surfaces to the originator and triggers reconnect.
Node → Client
Event (stored or live):{
"type": "Event",
"sub_id": "<string>",
"event": "<encrypted>"
}{
"type": "EOSE",
"sub_id": "<string>"
}Write Success: Receipt (same as HTTP)
{
"type": "Receipt",
"id": "<hex64>",
"hash": "<hex64>",
"timestamp": 1706000000000,
"sequencer": "<hex64>",
"seq": 42,
"sig": "<hex128>",
"seq_sig": "<hex128>"
}{
"type": "Error",
"code": "<CODE>",
"message": "<reason>"
}{
"type": "Closed",
"sub_id": "<string>",
"reason": "<reason_code>"
}| Reason | Description | Client Action |
|---|---|---|
access_revoked | Live tail rejected at session open: requester has neither a retention: "current" column in their current bitmask nor any open-ended retention: "snapshot" interval. | Permanent; re-subscribe on rejoin or permission restoration. |
no_access | Query accepted, but its seq / filter range does not overlap any reader interval the requester is allowed to read in this enclave. | Permanent for this query; a different seq range may succeed. |
live_access_ended | Subscription served historical events successfully and was streaming live, but an RBAC change closed the requester's open-ended interval mid-stream (Move(MEMBER → OUTSIDER), Revoke(admin), …). | Permanent; re-subscribe on rejoin to resume live tail. |
session_expired | Session token expired | Generate new session, re-subscribe |
enclave_terminated | Enclave was terminated | Permanent; no recovery |
enclave_paused | Enclave was paused | Wait for Resume event, then re-subscribe |
enclave_migrated | Enclave migrated to new node | Query Registry for new node, re-subscribe there |
upstream_closed | Emitted by a WebSocket aggregator (see Aggregator Pattern) when its shared upstream socket to the enclave node died. Per-subscription; other subs on the same client connection remain alive. | Re-subscribe (the aggregator opens a fresh upstream + backfill catches up). |
no_access vs live_access_ended vs access_revoked are normatively distinct outcomes — see Read Authorization for when each fires. Implementations targeting the snapshot read-authorization path SHOULD emit the most specific reason that applies.
{
"type": "Notice",
"message": "<string>"
}Node Processing
On Query:- Verify session (same as HTTP)
- Authorize the query per Read Authorization — compute the requester's served seq set; reject with
access_revoked(no readable intervals at all) orno_access(intervals exist but don't overlap the query) as defined there - Adopt the Query's
sub_idif present (non-empty string), else generate one; store subscription - Send matching events as
Eventmessages (encrypted), restricted to the served seq set - Send
EOSEmessage - On new events matching filter AND inside the requester's open-ended interval → push
Eventto client; on RBAC change that closes the open-ended interval, emitClosed { reason: "live_access_ended" }
Same as HTTP, returns Receipt.
On Close:Remove subscription. Terminate connection if none remain.
Read Authorization
The node's authorization decision per Query (HTTP or WebSocket) is normative; deviation produces wire-incompatible behavior — a client sees inconsistent close reasons or, worse, served events the requester was not entitled to.
The authorization rule has three concerns:
- Compute the requester's per-reader-column access intervals in the target enclave (a list of disjoint seq ranges).
- Intersect with the query's
seq/ filter range to obtain the served seq set and reject if empty. - For subscriptions, split into a historical phase and a live tail; the live tail terminates on RBAC events that close the requester's open-ended interval.
Access Intervals
For a (requester, enclave, reader) triple, where reader is a readers entry from the enclave's manifest (rbac/manifest.md §Section Details / Reader Retention), the access interval list I(requester, reader) is a sorted list of disjoint half-open seq intervals [[s0, e0), [s1, e1), …]:
reader.retention | reader.type | I(requester, reader) |
|---|---|---|
"current" (default) | State / trait | [[0, ∞)] if requester's CURRENT bitmask contains reader.type; else []. |
"snapshot" | State / trait | Replayed over CT as defined in Computing Snapshot Intervals below. |
| n/a | Self / Sender / Public | [[0, ∞)] — intervals span all seqs; the per-event Context predicate is checked at serve time, not in the interval set. |
The served seq set for the requester is then:
served_seq_set(requester) =
⋃ I(requester, r) for each r ∈ manifest.readerswith the per-event constraint that an event e is served only if e.type ∈ r.reads for at least one reader r whose interval contains e.seq AND, for Context readers, whose per-event predicate (Sender: e.from == requester; Self: e.from == requester for the event-as-actor case; Public: always true) holds.
The query's served events are then { e ∈ enclave.events : e.seq ∈ served_seq_set(requester) AND e matches query.filter AND the per-event Context constraint holds }.
Computing Snapshot Intervals
For retention: "snapshot" with column c (a State or trait), the node replays the requester's RBAC history in the enclave's CT:
1. Initialize bitmask = init_bitmask(requester) // per manifest.init, or 0 if absent
open = (c ∈ bitmask)
if open: emit interval-start at seq = 0
2. For each event e in CT, in seq order, where e affects requester:
(i.e. Move with target == requester,
Grant / Revoke with target == requester,
Transfer with operator == requester OR target == requester)
apply e per rbac/events.md → new_bitmask
new_open = (c ∈ new_bitmask)
if new_open AND NOT open:
emit interval-start at seq = (e.seq + 1)
if open AND NOT new_open:
emit interval-end at seq = (e.seq + 1)
bitmask, open = new_bitmask, new_open
3. If open at end-of-CT: the last interval is open-ended ([…, ∞)).Resulting intervals are half-open [start, end), sorted, disjoint, with the last entry's end either a concrete seq (column was lost) or ∞ (column is currently held). For Sub-keyed Transfer (rbac-v2 §8.4) where the same event mutates both operator and target, the same seq advance applies to both the operator's and target's interval lists.
The State enum value vs trait flag distinction does NOT matter for interval computation: both are bits in the same 256-bit RBAC bitmask (rbac-v2 §1.1); the interval ends the moment the bit is no longer set.
Caching. Implementations SHOULD cache I(requester, reader) per (enclave_id, requester_id_pub, reader_index).The cache MUST be invalidated when any of the following events finalize: Move with target == requester, Grant / Revoke with target == requester, Transfer with operator == requester OR target == requester.The SMT update path already detects these (the bitmask actually changed); the cache invalidation hook can ride on the same trigger.
Query Phases
A query has two distinct phases, evaluated separately:
| Phase | Seq range | Authorized iff |
|---|---|---|
| Historical | [query_start, current_seq) at query open | query_seq_range ∩ served_seq_set(requester) ≠ ∅ |
| Live tail | [current_seq, ∞) (only if the query has no seq.end_before cap or it exceeds current_seq) | At least one reader r exists where I(requester, r) contains an open-ended […, ∞) entry |
The two phases produce distinct close-reason outcomes:
- Historical phase empty →
Closed { reason: "no_access" }. Permanent for this query. - Historical phase non-empty but live phase rejected → serve the historical events, then emit
Closed { reason: "live_access_ended" }immediately after theEOSE. - Both phases empty (rare: every reader is
currentand requester has nothing) →Closed { reason: "access_revoked" }at query open, before anyEOSE. - Live phase was streaming and an RBAC change closes the requester's last open-ended interval → emit
Closed { reason: "live_access_ended" }(the same code, but mid-stream rather than at open).
access_revoked is the "the requester has no read access in this enclave at all" case; no_access is "the requester has some access but not in the slice asked for"; live_access_ended is "the requester had access and lost it (at open or mid-stream)." Implementations MUST emit the most specific reason.
Worked Examples
Assume the group manifest:
"readers": [ { "type": "MEMBER", "reads": "*", "retention": "snapshot" } ]and requester Alice's RBAC history in the enclave:
- seq=10:
Move(OUTSIDER → MEMBER, target=Alice)by admin - seq=400:
Move(MEMBER → OUTSIDER, target=Alice)by admin (kick) - seq=520:
Move(OUTSIDER → MEMBER, target=Alice)by admin (re-invite)
Then I(Alice, MEMBER) = [[11, 401), [521, ∞)]. Cases:
- Alice opens a fresh subscription with
seq.start_after: 0: historical[1, current_seq)intersects both intervals; serve all events in[11, 401) ∪ [521, current_seq). Live tail authorized (open-ended interval exists). Subscription stays open. - Alice (currently MEMBER) is kicked at seq=900 while subscribed: the second interval closes at
[521, 901). The node emitsClosed { reason: "live_access_ended" }. She keeps the historical events delivered before. - Alice (kicked, currently OUTSIDER) re-subscribes asking for
seq.start_after: 500, seq.end_before: 600: range is[501, 600). Intersected withI = [[11, 401), [521, …)]gives[521, 600). Historical serves; the seq cap means there is no live phase, nolive_access_endedever fires. - Alice (kicked) asks for
seq.start_after: 600, seq.end_before: 800: range is[601, 800). Empty intersection withI(her second interval starts at 521 and ended at 401 — wait no, scratch — assume here she's mid-gap withI = [[11, 401)]because she has not yet been re-invited). Empty intersection →Closed { reason: "no_access" }. - Alice (was never in this group, current bitmask 0) opens any subscription:
I(Alice, MEMBER) = []under snapshot, and no other reader applies. Both phases empty →Closed { reason: "access_revoked" }.
For retention: "current", the same Alice mid-kick gets I = [] immediately (current bitmask lacks MEMBER) — access_revoked on open, even for queries against seqs she could read under snapshot semantics. This is the design point of the field.
Performance Bound (Non-Normative)
Per-query cost is O(R + I + Q):
R= manifest readers (typically < 10)I= events targetingrequesterin the CT — Move / Grant / Revoke / Transfer (typically < 10 per identity per enclave)Q= events served (bounded byquery.limit)
The cached interval list per (requester, enclave, reader) is on the order of tens of bytes; cache hits are O(R + Q). The "snapshot" path is strictly no more expensive than a per-event SMT lookup in the legacy current-only path — both already require checking authorization against state, and snapshot collapses that to one interval test per query instead of per event.
Self-Authored Content (Sender Context)
A readers entry with type: "Sender" is satisfied per-event by event.from == requester. The node serves these regardless of the requester's State / trait history. A kicked former MEMBER who authored messages during their membership window MUST be able to query and recover those messages via the Sender reader, even when the corresponding MEMBER reader is retention: "current" and currently denies.This preserves the requester's ability to export their own content irrespective of RBAC outcome.
A manifest that wants to disable this property MUST omit the Sender reader entry — there is no separate switch.
Connection Lifecycle
Termination conditions:- All subscriptions closed by client
- All sessions expired
- All access revoked
- Client disconnects
- One connection, multiple identities (
from) - Each identity has own session
- Session expiry only affects that identity's subscriptions
HTTP vs WebSocket
| Aspect | HTTP | WebSocket |
|---|---|---|
| Query | One-time response | Subscribe + live updates |
| sub_id | N/A | Node-assigned, or caller-supplied |
| Commit | Receipt | Receipt |
| Session | Per-request | Cached per connection |
| State | Stateless | Subscriptions |
Proof Retrieval API
Endpoints for retrieving cryptographic proofs. Used by clients to verify events and state.
Access Control:| Endpoint | Access |
|---|---|
| STH | Public |
| Consistency | Public |
| Inclusion | Requires R permission |
| State | Requires R permission |
STH (Signed Tree Head)
GET /:enclave/sth
Returns the current signed tree head. Public endpoint for auditing.
Response (200 OK):{
"t": 1706000000000,
"ts": 1000,
"r": "<hex64>",
"sig": "<hex128>"
}See proof.md for STH structure and verification.
Consistency Proof
GET /:enclave/consistency?from=<tree_size>&to=<tree_size>
Returns consistency proof between two tree sizes. Public endpoint for auditing.
| Parameter | Type | Description |
|---|---|---|
| from | uint | Earlier tree size |
| to | uint | Later tree size (omit for current) |
{
"ts1": 500,
"ts2": 1000,
"p": ["<hex64>", ...]
}See proof.md for verification algorithm.
Errors:| Code | HTTP | Description |
|---|---|---|
INVALID_RANGE | 400 | from > to or invalid values |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
Inclusion Proof
POST /inclusion
Returns inclusion proof for a bundle. Requires R permission.
Request:{
"type": "Inclusion_Proof",
"enclave": "<hex64>",
"from": "<hex64>",
"content": "<encrypted>"
}{
"session": "<hex136>",
"leaf_index": 42
}{
"type": "Response",
"content": "<encrypted>"
}{
"ts": 1000,
"li": 42,
"p": ["<hex64>", ...],
"events_root": "<hex64>",
"state_hash": "<hex64>"
}| Field | Description |
|---|---|
| ts | Tree size when proof was generated |
| li | Leaf index |
| p | Inclusion proof path |
| events_root | Merkle root of event IDs in bundle |
| state_hash | SMT root after bundle |
See proof.md for verification algorithm.
Errors:| Code | HTTP | Description |
|---|---|---|
INVALID_SESSION | 400 | Session token verification failed |
SESSION_EXPIRED | 401 | Session token expired |
UNAUTHORIZED | 403 | No read permission |
LEAF_NOT_FOUND | 404 | Leaf index out of range |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
Bundle Membership Proof
POST /bundle
Returns bundle membership proof for an event. Requires R permission.
Request:{
"type": "Bundle_Proof",
"enclave": "<hex64>",
"from": "<hex64>",
"content": "<encrypted>"
}{
"session": "<hex136>",
"event_id": "<hex64>"
}{
"leaf_index": 42,
"ei": 2,
"s": ["<hex64>", ...],
"events_root": "<hex64>"
}| Field | Description |
|---|---|
| leaf_index | Bundle's position in CT tree |
| ei | Event index within bundle |
| s | Siblings for bundle membership proof |
| events_root | Merkle root of event IDs in bundle |
See proof.md for verification algorithm.
Errors:| Code | HTTP | Description |
|---|---|---|
INVALID_SESSION | 400 | Session token verification failed |
SESSION_EXPIRED | 401 | Session token expired |
UNAUTHORIZED | 403 | No read permission |
EVENT_NOT_FOUND | 404 | Event doesn't exist |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
State Proof
POST /state
Returns SMT proof for a key. Requires R permission.
Request:{
"type": "State_Proof",
"enclave": "<hex64>",
"from": "<hex64>",
"content": "<encrypted>"
}{
"session": "<hex136>",
"namespace": "rbac" | "event_status",
"key": "<hex64>",
"tree_size": 1000
}| Field | Required | Description |
|---|---|---|
| session | Yes | Session token |
| namespace | Yes | "rbac" or "event_status" |
| key | Yes | Identity public key (rbac) or event ID (event_status) |
| tree_size | No | Bundle index for historical state (omit for current) |
{
"k": "<hex42>",
"v": "<hex | null>",
"b": "<hex42>",
"s": ["<hex64>", ...],
"state_hash": "<hex64>",
"leaf_index": 999
}| Field | Description |
|---|---|
| k, v, b, s | SMT proof fields (see proof.md) |
| state_hash | SMT root hash for verification |
| leaf_index | Bundle index (0-based) containing this state |
To fully verify a state proof is authentic and from the requested tree position:
- Verify SMT proof against
state_hash(see proof.md) - Request CT inclusion proof for
leaf_indexviaPOST /inclusion - Verify CT inclusion: Recompute leaf as
H(0x00, events_root, state_hash)and verify against signed CT root - Verify STH signature to authenticate the CT root
This binds the SMT state to a specific, signed tree checkpoint. See proof.md for detailed algorithms.
Errors:| Code | HTTP | Description |
|---|---|---|
INVALID_SESSION | 400 | Session token verification failed |
SESSION_EXPIRED | 401 | Session token expired |
INVALID_NAMESPACE | 400 | Unknown namespace |
UNAUTHORIZED | 403 | No read permission |
TREE_SIZE_NOT_FOUND | 404 | Historical state not available |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
Batched State Proof
POST /state-batch
Returns SMT proofs for many keys against a single state_hash, in one round trip. Required by the cache-reconciliation flow (§Subscribe Replay Semantics): on rehydrate-from-archive a client checks eventstatus:<id> for every cached message, which is O(N) leaves; the batch endpoint collapses those N lookups into one request bound to one signed root.
Requires R permission. Cache-reconciliation is the load-bearing caller; rbac keys can be batched too (e.g. computing a per-identity capability matrix on first connect).
{
"type": "State_Proof_Batch",
"enclave": "<hex64>",
"from": "<hex64>",
"content": "<encrypted>"
}{
"session": "<hex136>",
"namespace": "rbac" | "event_status",
"keys": ["<hex42>", "<hex42>", ...],
"tree_size": 1000
}| Field | Required | Description |
|---|---|---|
| session | Yes | Session token |
| namespace | Yes | All keys MUST share one namespace; cross-namespace batches MUST be rejected with INVALID_NAMESPACE |
| keys | Yes | Array of <hex42> SMT keys to look up; max 1000 per request |
| tree_size | No | Bundle index for historical state (omit for current) |
| Field | Max |
|---|---|
keys | 1000 |
A node MUST reject a batch larger than the limit with BATCH_TOO_LARGE. The 1000-key cap matches the limit cap on Query / Pull pagination (§Filter Limits) so a single batched-proof round trip covers a single page of cached events without further chunking.
{
"state_hash": "<hex64>",
"leaf_index": 999,
"proofs": [
{ "k": "<hex42>", "v": "<hex | null>", "b": "<hex42>", "s": ["<hex64>", ...] },
{ "k": "<hex42>", "v": "<hex | null>", "b": "<hex42>", "s": ["<hex64>", ...] }
]
}| Field | Description |
|---|---|
| state_hash | SMT root hash; ONE root for the whole batch, MUST be the same root every per-key proof verifies against |
| leaf_index | Bundle index (0-based) containing this state |
| proofs | Array, same length and order as request keys; each entry has the same k/v/b/s shape as the single-key /state response |
The batch response MUST satisfy two invariants:
- Same root for every proof. Every entry in
proofsis an SMT proof against the SAMEstate_hash. The client verifies all entries against the single returned root. - Order preservation. The response's
proofsarray MUST be the same length as the request'skeysarray, andproofs[i]MUST be the proof forkeys[i]. Clients MAY rely on positional alignment.
Same as /state, applied once to the shared state_hash:
- For each
proofs[i], verify SMT proof againststate_hash(see proof.md). - Request CT inclusion proof for
leaf_indexviaPOST /inclusion(ONCE; the sameleaf_indexcovers every per-key proof in the batch). - Verify CT inclusion as in the single-key flow.
- Verify STH signature.
The savings: one CT-inclusion + one STH-signature verification per batch instead of per key.
Cache-reconciliation usage:{
"type": "State_Proof_Batch",
"content": {
"session": "...",
"namespace": "event_status",
"keys": ["<eventstatus:abc...>", "<eventstatus:def...>", "<eventstatus:123...>"]
}
}For each proofs[i], the client checks the returned v: null (absent) or 0x00… (active) means the event is live; 0x01 (deleted) means the event is dropped from the cache. The membership proof is verified against state_hash once.
| Code | HTTP | Description |
|---|---|---|
INVALID_SESSION | 400 | Session token verification failed |
SESSION_EXPIRED | 401 | Session token expired |
INVALID_NAMESPACE | 400 | Unknown namespace, or batch carried multiple namespaces |
BATCH_TOO_LARGE | 400 | keys.length exceeds the 1000 limit |
UNAUTHORIZED | 403 | No read permission |
TREE_SIZE_NOT_FOUND | 404 | Historical state not available |
ENCLAVE_NOT_FOUND | 404 | Enclave doesn't exist |
Webhook Delivery
Node delivers Push messages via HTTPS POST to registered endpoints. See Appendix: Push/Notify for design rationale.
Grant with Push Endpoint
A Grant event with P (push) ops and an endpoint field registers a webhook endpoint. See spec.md for event structure and content fields.
Node maintains queue per (identity, url):
- Aggregates events from all enclaves on this node where identity has P/N permission
- Tracks single
push_seqper queue - All enclaves in a Push delivery share the node's
seq_privfor encryption
An identity MAY register multiple webhook endpoints via separate Grant events with different endpoint values.Each (identity, url) pair has its own push_seq and event queue. Events are delivered independently to each endpoint. To replace an endpoint, submit a new Grant with the new URL; both endpoints remain active until explicitly revoked.
Events within each enclave (push.enclaves[N].events) are ordered by sequence number ascending. Events from different enclaves have no guaranteed relative ordering. If global ordering is required, use per-enclave seq to reconstruct the timeline.
When a Grant changes the webhook URL (new Grant with the same trait, different URL):
- Old and new endpoints operate as separate queues (no events lost)
- Old endpoint receives events finalized before the Grant
- New endpoint receives events finalized after the Grant
- To stop delivery to old endpoint, explicitly Revoke the trait
Delivery Flow
1. Enclave submits Grant event with P ops + endpoint (grants trait + registers url)
2. Node adds enclave to (identity, url) queue if not exists
3. On new event, node checks the trait's P/N permissions
4. Node aggregates events into (identity, url) queue
5. Node periodically POSTs Push to url
6. Node increments push_seqPush
Webhook delivery containing full events (P permission) and/or event IDs (N permission).
HTTP Request:POST <url>
Content-Type: application/json{
"type": "Push",
"from": "<hex64>",
"to": "<hex64>",
"url": "<string>",
"content": "<encrypted>"
}| Field | Type | Description |
|---|---|---|
| type | string | Always "Push" |
| from | hex64 | Sequencer public key |
| to | hex64 | Recipient identity |
| url | string | Webhook URL |
| content | string | Encrypted payload |
{
"push_seq": 130,
"push": {
"enclaves": [
{ "enclave": "<hex64>", "events": [Event, ...] }
]
},
"notify": {
"enclaves": [
{ "enclave": "<hex64>", "seq": 150 }
]
}
}| Field | Type | Description |
|---|---|---|
| push_seq | uint | Sequence number per (identity, url) |
| push | object | Full events for enclaves with P permission |
| push.enclaves[].enclave | hex64 | Enclave ID |
| push.enclaves[].events | array | Array of Event objects |
| notify | object | Latest seq for enclaves with N permission |
| notify.enclaves[].enclave | hex64 | Enclave ID |
| notify.enclaves[].seq | uint | Latest sequence number in this enclave |
Either push or notify can be omitted if empty.
Encryption: See Encryption.
Delivery semantics:- At-least-once delivery
- Exponential backoff on failure
- Recipient MUST dedupe by
event.id(for push) or track last synced seq (for notify)
Expected response: 200 OK
Pull Fallback
If webhook delivery fails, recipient can pull missed batches.
Request:{
"type": "Pull",
"enclave": "<hex64>",
"from": "<hex64>",
"content": "<encrypted>"
}Note: enclave is required in the outer request for signer key derivation during decryption. Any enclave on the node where the identity has a registered webhook can be used.
{
"session": "<hex136>",
"url": "<string>",
"push_seq": { "start_after": 5, "end_at": 7 },
"enclave": "<hex64>"
}| Field | Type | Required | Description |
|---|---|---|---|
| session | hex136 | Yes | Session token |
| url | string | Yes | Registered webhook endpoint |
| push_seq | uint, [uint,.], or Range | Yes | Batch sequence(s) to retrieve |
| enclave | hex64 | No | Filter results to single enclave |
- Single:
6— returns batch 6 - Array:
[6, 7, 8]— returns batches 6, 7, 8 - Range:
{ "start_after": 5, "end_at": 7 }— returns batches 6, 7
{
"type": "Response",
"content": "<encrypted>"
}[
{
"push_seq": 6,
"push": { "enclaves": [...] },
"notify": { "enclaves": [...] }
},
{
"push_seq": 7,
"push": { "enclaves": [...] },
"notify": { "enclaves": [...] }
}
]Array of batches. Each batch has same structure as Push delivery content.
Range Handling:When push_seq is a Range:
start_after/end_atare inclusive/exclusive as documented- Results are ordered by
push_seqascending - If
enclavefilter is provided, only events from that enclave are included in each batch - If
enclaveis omitted, all enclaves in the original batch are included
Node retries webhook delivery with exponential backoff per spec.md. After max retries exhausted, batch is moved to dead-letter queue. Recipient can recover via Pull fallback.
Encryption: Same as Query. See Encryption.
Registry DataView API
Registry-specific endpoints (/nodes/:seq_pub, /enclaves/:enclave_id, /identity/:id_pub) are specified in enclaves/registry.md §DataView API. They are NOT part of the generic node API; only nodes hosting the Registry enclave expose them.
Snapshot Endpoints
Two endpoints move complete enclave state between nodes. They are the wire protocol for the migration flow specified in migration.md, and they're equally useful for backup, archival, and audit handoff.
The byte format (.enc) is normative; see migration.md §Enclave Snapshot Format for the layout, kernel-version compatibility rules, and verification procedure. This section specifies the HTTP surface.
GET /enclaves//snapshot
Download the complete enclave state.
Path parameters:| Param | Type | Description |
|---|---|---|
id | hex64 | Enclave id |
| Header | Required | Description |
|---|---|---|
Authorization | Implementation-defined | Most production deployments require an auth token; a public snapshot endpoint is the exception, not the default. |
| Header | Value |
|---|---|
Content-Type | application/octet-stream |
Content-Length | 32 + payload_size + 32 |
Content-Disposition | OPTIONAL attachment; filename="<enclave_id>.enc" |
Body: the raw .enc bytes (header || payload || footer; see migration spec).
| Code | HTTP | Description |
|---|---|---|
ENCLAVE_NOT_FOUND | 404 | This node does not host the requested enclave. |
ENCLAVE_PAUSED | 409 | Enclave is paused; some implementations refuse snapshot of a paused enclave to avoid capturing transient state. Caller MAY retry after Resume. |
SNAPSHOT_UNSUPPORTED | 501 | This node does not support snapshot export (e.g., proxy-only nodes). |
POST /enclaves//restore
Bootstrap a fresh enclave on this node from a .enc payload. The receiving node MUST be able to host the enclave id and MUST NOT already have state for it.
::: extension-point id=enclave-restore-overwrite-mechanism class=local_policy reason: an overwrite path for non-fresh restore is out of scope for the core protocol but operators can add one (with their own consent and authorization rules)
An explicit overwrite mechanism for restoring on top of existing enclave state is out of scope for the core protocol. Operators can provide one with deployment-specific authorization; the wire shape of the restore call is unchanged. :::
Path parameters:| Param | Type | Description |
|---|---|---|
id | hex64 | Enclave id the snapshot will be restored as |
| Header | Required | Description |
|---|---|---|
Content-Type | Yes | application/octet-stream |
Content-Length | Yes | Total bytes; MUST equal 32 + payload_size + 32 |
Authorization | Implementation-defined | Restore is a privileged operation; production deployments typically require Owner credentials. |
Body: the raw .enc bytes.
Response (200 OK): the enclave is live.
{
"type": "Restored",
"id": "<hex64>",
"kernel_ver": "<major.minor.patch>",
"events": 1234,
"last_seq": 1233,
"ct_root": "<hex64>"
}| Code | HTTP | Description |
|---|---|---|
BAD_SNAPSHOT_MAGIC | 400 | Header magic ≠ b"ENC\x01". |
UNKNOWN_LAYOUT_VERSION | 400 | layout_ver is not 1. |
SNAPSHOT_FOOTER_MISMATCH | 400 | Computed sha256 footer does not match stored. |
KERNEL_VERSION_MISMATCH | 400 | kernel_ver falls outside the compatibility window (always: differing major; pre-1.0: any difference). The error body MUST include both producer and restorer versions. |
SNAPSHOT_TOO_LARGE | 413 | payload_size exceeds the node's configured limit. |
SELF_TEST_FAILED | 422 | Post-restore self-test rejected. |
ENCLAVE_ALREADY_EXISTS | 409 | This node already hosts an enclave with this id; restoration would overwrite. |
RESTORE_UNSUPPORTED | 501 | This node does not support restore. |
KERNEL_VERSION_MISMATCH body shape:
{
"type": "Error",
"code": "KERNEL_VERSION_MISMATCH",
"producer": "0.12.3",
"restorer": "0.13.0",
"message": "snapshot was written by kernel 0.12.3; this node runs 0.13.0; pre-1.0 kernels do not accept any version drift"
}Migration Flow Using These Endpoints
For Peaceful Handoff:
1. Owner submits Migrate commit to old node.
2. Old node finalizes Migrate; closes current bundle.
3. Old node responds to GET /enclaves/<id>/snapshot with .enc bytes.
4. New node receives bytes; calls POST /enclaves/<id>/restore on itself
(or the orchestrator POSTs the bytes to the new node directly).
5. New node verifies header + footer + kernel_ver + self-test, then
verifies that the Migrate event in the restored log matches the
commit it has (sequencer transition is real).
6. New node becomes the sequencer; sequencing resumes at prev_seq + 2.For Forced Takeover, the Backup holds the .enc bytes (produced by an
earlier GET /snapshot while the old node was alive); the new node
verifies + restores + finalizes Migrate as the last event.
Push/Notify
Problem
A DataView server can have P/N permissions across hundreds of enclaves on the same node. Naive approach — one HTTP request per event per enclave — creates massive overhead.
Solution
Node aggregates events into a single queue per (identity, url) pair.
Enclave A ──┐
Enclave B ──┼──► Queue (identity, url) ──► Single POST to url
Enclave C ──┘Why This Is Efficient
| Aspect | Benefit |
|---|---|
| Batching | One POST delivers events from many enclaves |
| Single sequence | One push_seq for gap detection across all enclaves |
| Periodic aggregation | Node batches events instead of instant push per event |
| Unified message | Push and Notify combined in single delivery |
Pull Fallback Efficiency
When webhook delivery fails, recipient uses Pull to recover. The single sequence number makes this extremely efficient:
Without unified sequence (naive):Recipient must track:
- Enclave A: last_seq = 42
- Enclave B: last_seq = 17
- Enclave C: last_seq = 103
...hundreds of enclaves...
Recovery requires:
- One Query per enclave
- Complex state management
- N round trips for N enclavesRecipient tracks:
- push_seq = 130
Recovery requires:
- One Pull request with Range
- Returns all missed batches
- Single round trip- Received push_seq 5, then 8 → the gap implies missed 6, 7
- Pull with
push_seq: { "start_after": 5, "end_at": 7 }returns both batches in one request
This is why the queue is per (identity, url) not per enclave — it enables O(1) state tracking regardless of subscription count.
Push vs Notify (within same message)
A single Push delivery contains both:
push.enclaves[]— full events for enclaves with P permissionnotify.enclaves[]— latest seq for enclaves with N permission
| push | notify | |
|---|---|---|
| Content | Full events | Latest seq only |
| Use case | Real-time sync | Lightweight alerts |
Permissions
| Permission | What it grants |
|---|---|
| P (Push) | Receive full event content in push.enclaves[] |
| N (Notify) | Receive latest seq in notify.enclaves[] |
| R (Read) | Query full event content from enclave |
Important: P delivers full content directly. N only delivers the latest seq; fetching full events requires R permission on that enclave.
Example:Identity has:
- Enclave A: P permission → full events in push.enclaves[]
- Enclave B: N permission → seq in notify.enclaves[]
- Enclave C: N + R permissions → seq in notify.enclaves[], can Query full eventsIf the requester only has N (no R), new events are observable but their content is not readable. This is useful for:
- Mobile push notifications (just alert user)
- Protocol-level sync signals (trigger sync via other means)
- Audit logging (record that activity occurred)
Typical Pattern
- Receive Push with notify.enclaves[].seq
- Query events with
{ "seq": { "start_after": last_synced_seq } }(requires R permission) - Full events arrive directly in push.enclaves[] if the requester has P permission
Node Internal Table
Key: (identity, url)
Value: { push_seq, enclaves[] }For each enclave, node tracks which events to include based on the identity's State + trait bitmask (and its P / N permissions on each event type).
Encryption
Client-node communication is encrypted using ECDH + XChaCha20-Poly1305.
Overview
| Context | Client Key | Node Key | HKDF Label |
|---|---|---|---|
| Query request | signer_priv | seq_pub | "enc:query" |
| Query response | signer_priv | seq_pub | "enc:response" |
| Pull request | signer_priv | seq_pub | "enc:query" |
| Pull response | signer_priv | seq_pub | "enc:response" |
| WebSocket event | signer_priv | seq_pub | "enc:response" |
| Push delivery | to (recipient) | seq_priv | "enc:push" |
Query Encryption (Client → Node)
Client encrypts query content:
1. Derive signer from session:
t = sha256(session_pub || seq_pub || enclave)
signer_priv = session_priv + t (mod n)
2. Compute shared secret:
shared = ECDH(signer_priv, seq_pub)
3. Derive key and encrypt:
key = HKDF(shared, "enc:query")
ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext)Node decrypts:
1. Derive signer_pub from session_pub:
t = sha256(session_pub || seq_pub || enclave)
signer_pub = session_pub + t * G
2. Compute shared secret:
shared = ECDH(seq_priv, signer_pub)
3. Decrypt:
key = HKDF(shared, "enc:query")
plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext)Response Encryption (Node → Client)
Node encrypts response (HTTP and WebSocket):
shared = ECDH(seq_priv, signer_pub)
key = HKDF(shared, "enc:response")
ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext)Client decrypts:
shared = ECDH(signer_priv, seq_pub)
key = HKDF(shared, "enc:response")
plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext)Push Encryption (Node → Webhook)
Node encrypts webhook payload:
shared = ECDH(seq_priv, to)
key = HKDF(shared, "enc:push")
ciphertext = XChaCha20Poly1305_Encrypt(key, plaintext)Recipient decrypts:
shared = ECDH(recipient_priv, seq_pub)
key = HKDF(shared, "enc:push")
plaintext = XChaCha20Poly1305_Decrypt(key, ciphertext)Primitives
| Primitive | Specification |
|---|---|
| ECDH | secp256k1 |
| HKDF | HKDF-SHA-256 |
| AEAD | XChaCha20-Poly1305 |
IKM = ECDH shared secret (32 bytes)
salt = empty (no salt)
info = UTF-8 encoded label string, NO null terminator
e.g., "enc:query" = 9 bytes: 0x65 0x6E 0x63 0x3A 0x71 0x75 0x65 0x72 0x79
L = 32 bytes (256-bit key)nonce = random 24 bytes, prepended to ciphertext
ciphertext_wire = nonce || ciphertext || tagRecipient extracts first 24 bytes as nonce before decryption.
Minimum Length: ciphertext_wire MUST be at least 40 bytes (24-byte nonce + 16-byte Poly1305 tag).Shorter values indicate malformed or truncated ciphertext — implementations MUST reject with DECRYPT_FAILED.
Error Codes
HTTP Status Mapping
| HTTP | Category |
|---|---|
| 400 | Client error (malformed request) |
| 401 | Authentication error |
| 403 | Authorization error |
| 404 | Not found |
| 409 | Conflict |
| 410 | Gone |
| 429 | Rate limited |
| 500 | Internal server error |
| 502 | Upstream unreachable |
| 503 | Service temporarily unavailable |
Error Response Format
{
"type": "Error",
"code": "<CODE>",
"message": "<human readable>"
}Error Codes
| Code | HTTP | Description |
|---|---|---|
INVALID_COMMIT | 400 | Malformed commit structure |
INVALID_HASH | 400 | Hash doesn't match CBOR encoding |
INVALID_SIGNATURE | 400 | Signature verification failed |
INVALID_QUERY | 400 | Malformed query structure |
INVALID_SESSION | 400 | Session token verification failed |
INVALID_FILTER | 400 | Malformed filter |
DECRYPT_FAILED | 400 | Cannot decrypt content |
SESSION_EXPIRED | 401 | Session token expired |
EXPIRED | 400 | Commit exp < current time |
UNAUTHORIZED | 403 | Insufficient RBAC permissions |
ENCLAVE_PAUSED | 403 | Enclave is paused |
DUPLICATE | 409 | Commit hash already processed |
NODE_NOT_FOUND | 404 | Node not registered |
ENCLAVE_NOT_FOUND | 404 | Enclave not registered |
IDENTITY_NOT_FOUND | 404 | Identity not registered |
ENCLAVE_TERMINATED | 410 | Enclave is terminated |
ENCLAVE_MIGRATED | 410 | Enclave has migrated to another node |
RATE_LIMITED | 429 | Too many requests |
INTERNAL_ERROR | 500 | Internal server error |
WebSocket errors use the same JSON format as HTTP errors. HTTP status codes do not apply to WebSocket; use the application-level code field instead.
Rejection Error Envelope
When a node rejects a commit or request, it MUST return an error response:
{
"type": "Error",
"code": "<ERROR_CODE>",
"message": "<human-readable description>",
...additional fields specific to the error...
}| Field | Required | Description |
|---|---|---|
| type | Yes | Always "Error" |
| code | Yes | Machine-readable error code (UPPER_SNAKE_CASE) |
| message | Yes | Human-readable description |
| additional | No | Error-specific context (see below) |
The canonical code strings and HTTP statuses are defined in §Error Codes. The table below lists additional RBAC and lifecycle rejection codes whose context fields are not already described by the canonical registry.
Additional Rejection Codes:| Code | Context Fields | Description |
|---|---|---|
UNAUTHORIZED | — | Sender lacks required permission |
STATE_MISMATCH | expected, actual | Move target's current State does not match from |
RANK_INSUFFICIENT | — | Operator's rank is not strictly less than target's rank |
INVALID_STATE_FOR_GRANT | — | Target's State is not in Grant/Revoke scope |
INVALID_STATE_FOR_TRANSFER | — | Target's State is not in Transfer scope |
INVALID_TRANSFER_TARGET | — | Transfer target is the operator (self-transfer) |
TRAIT_ALREADY_HELD | — | Transfer target already holds the trait |
INVALID_LIFECYCLE_STATE | — | Lifecycle transition not valid from current state |
AC_BUNDLE_FAILED | failed_index, reason | AC_Bundle operation failed at index |
EVENT_DELETED | — | Target event has been deleted (Self/D on deleted event) |
ENCLAVE_PAUSED | — | Enclave is paused, only Resume/Terminate/Migrate accepted |
ENCLAVE_TERMINATED | — | Enclave is terminated, no events accepted |
ENCLAVE_MIGRATED | — | Enclave has migrated to another node |
ENCLAVE_NOT_FOUND | — | Enclave ID not found on this node |